Skip to content

Prompts

The imbi.common.prompts package holds the prompt CMS code that imbi-api and the prompt consumers share. A consumer is a service that reads its system prompt from the CMS, such as imbi-assistant and imbi-slackbot. For the operator view of the CMS, see Prompts.

Module What it does
system Gives a consumer its system prompt, with a packaged fallback
resolve Reads prompts and versions from the graph and resolves references
rendering Renders templates in a Jinja2 sandbox with resource limits
pool Keeps warm worker processes that render templates
worker The worker process (python -m imbi.common.prompts.worker)

References

A reference names a prompt version:

Form Selects
namespace/slug The version that has the prompt's default label
namespace/slug@label The version that has the label. If the prompt does not have the label, the default label
namespace/slug@n Version n

resolve.resolve(db, ref) returns the prompt, the version, and the label. It raises InvalidRef for a bad reference and PromptNotFound when the prompt, the version or a usable label does not exist. Both are subclasses of PromptError.

Load a system prompt

system.load_system_prompt() returns a SystemPrompt. It tries three sources in this order:

  1. override: a template that the operator supplies, for example in an environment setting.
  2. The CMS version that ref names.
  3. The DefaultPrompt that is packaged with Imbi.

A failure in the CMS path, such as a missing prompt or a render error, is logged. The function then uses the packaged prompt. A broken prompt therefore never stops a conversation. SystemPrompt.source tells you which source was used.

from imbi.common.prompts import system

prompt = await system.load_system_prompt(
    db,
    'imbi-assistant/system@stable',
    {'display_name': 'Pat', 'email': 'pat@example.com', ...},
    system.ASSISTANT,
)

The function passes only the variables that the version declares. An editor can therefore remove a variable from a version without a render error.

When the CMS version names an enabled AI model that an anthropic provider serves, SystemPrompt.model_id holds its model ID. In all other cases it is None, and the consumer uses its configured model.

Render limits

rendering.render() renders the system template and each message of a version. It raises RenderError when the variables are not valid, the template fails, or the template breaks a limit. The sandbox blocks private attributes and Python internals. A template sees only the variables and the registered providers.

Limit Value
Rendered output 262144 characters (MAX_OUTPUT)
Work units for one render 4194304 (MAX_WORK)
Work units for one loop iteration 256 (ITERATION_COST)
range() length 1000 (MAX_RANGE)
Loop nesting 2 (MAX_LOOP_DEPTH)
Wall clock, including provider calls 2 seconds (RENDER_TIMEOUT)

Macros and recursive loops are not allowed. Only principals with prompt:update can save a template.

Render workers

Renders run in separate worker processes. A worker has no database access and no credentials. When a template calls a provider, the worker asks the parent process to run it. The parent kills a worker that runs too long, crashes, or sends too much output, and starts a new one.

Add pool.pool_lifespan to the service lifespan to start the workers at startup and stop them at shutdown. imbi-api, imbi-assistant, imbi-slackbot and imbi-workflows do this. If the workers cannot start, the service still starts, and a render starts a worker when it needs one.

Variable Default Description
IMBI_PROMPT_WORKERS 2 Workers for each service process
IMBI_PROMPT_WORKER_MAX_RENDERS 500 Renders before the pool replaces a worker
IMBI_PROMPT_WORKER_MEMORY_MB 256 Address space that a worker can add after it starts. Linux only
IMBI_PROMPT_RENDER_INPROCESS false Render in the service process. For debugging only: it removes the isolation

API Reference

System prompts

load_system_prompt async

load_system_prompt(
    db: Graph | None,
    ref: str,
    variables: Mapping[str, str],
    default: DefaultPrompt,
    *,
    override: str | None = None,
) -> SystemPrompt

Return the consumer's system prompt.

Parameters:

Name Type Description Default
db Graph | None

The graph, or None when it is not open yet (the packaged prompt is used).

required
ref str

The CMS reference, such as imbi-assistant/system@stable.

required
variables Mapping[str, str]

The consumer's template variables. Only the variables the version declares are passed to it, so an editor may drop one without breaking the render.

required
default DefaultPrompt

The packaged prompt used when the CMS cannot answer.

required
override str | None

An operator-supplied template that replaces both.

None
Source code in libraries/common/src/imbi/common/prompts/system.py
async def load_system_prompt(
    db: graph.Graph | None,
    ref: str,
    variables: collections.abc.Mapping[str, str],
    default: DefaultPrompt,
    *,
    override: str | None = None,
) -> SystemPrompt:
    """Return the consumer's system prompt.

    Parameters:
        db: The graph, or ``None`` when it is not open yet (the
            packaged prompt is used).
        ref: The CMS reference, such as ``imbi-assistant/system@stable``.
        variables: The consumer's template variables. Only the variables
            the version declares are passed to it, so an editor may drop
            one without breaking the render.
        default: The packaged prompt used when the CMS cannot answer.
        override: An operator-supplied template that replaces both.

    """
    if override:
        try:
            return SystemPrompt(
                text=await _render(override, variables), source='override'
            )
        except rendering.RenderError:
            LOGGER.exception(
                'Failed to render the system prompt override for %s; '
                'using the packaged prompt',
                default.ref,
            )
            return await _fallback(default, variables)

    if db is None:
        return await _fallback(default, variables)

    try:
        prompt, version, _label = await resolve.resolve(db, ref)
        declared = {
            name: value
            for name, value in variables.items()
            if name in version.variable_schema
        }
        rendered = await rendering.render(version, declared, {})
        model_id = await _model_id(db, version, ref)
    except resolve.PromptError as e:
        LOGGER.info('Using the packaged prompt for %s: %s', ref, e)
        return await _fallback(default, variables)
    except Exception:  # noqa: BLE001 - a turn must never fail on this
        LOGGER.warning(
            'Failed to load prompt %s from the CMS; using the packaged prompt',
            ref,
            exc_info=True,
        )
        return await _fallback(default, variables)

    return SystemPrompt(
        text=rendered.system,
        source='cms',
        ref=f'{prompt.namespace}/{prompt.slug}@{version.n}',
        model_id=model_id,
        max_tokens=version.params.max_tokens,
        temperature=version.params.temperature,
    )

load_model_id async

load_model_id(db: Graph, ref: str) -> str | None

Return the model id of ref's version, when a consumer can call it.

None when the prompt is missing, names no model, or names one that is disabled or served by an unsupported driver. Errors are logged, never raised.

Source code in libraries/common/src/imbi/common/prompts/system.py
async def load_model_id(db: graph.Graph, ref: str) -> str | None:
    """Return the model id of ``ref``'s version, when a consumer can call it.

    ``None`` when the prompt is missing, names no model, or names one
    that is disabled or served by an unsupported driver. Errors are
    logged, never raised.
    """
    try:
        _prompt, version, _label = await resolve.resolve(db, ref)
        return await _model_id(db, version, ref)
    except resolve.PromptError:
        return None
    except Exception:  # noqa: BLE001 - creating a conversation must not fail
        LOGGER.warning('Failed to read the model of %s', ref, exc_info=True)
        return None

SystemPrompt dataclass

SystemPrompt(
    text: str,
    source: Source,
    ref: str | None = None,
    model_id: str | None = None,
    max_tokens: int | None = None,
    temperature: float | None = None,
)

A rendered system prompt and the settings that came with it.

DefaultPrompt dataclass

DefaultPrompt(
    namespace: str,
    slug: str,
    name: str,
    description: str,
    template: str,
    variables: tuple[str, ...],
    type: str = 'core_system',
)

A prompt Imbi ships, used as the fallback and as seed data.

References

resolve async

resolve(
    db: Graph, ref: str
) -> tuple[models.Prompt, models.PromptVersion, str | None]

Find the version a reference names.

Returns:

Type Description
Prompt

The prompt, the version, and the label used (None when the

PromptVersion

reference named a version number).

Raises:

Type Description
InvalidRef

The reference is not valid.

PromptNotFound

The prompt, version, or a usable label does not exist.

Source code in libraries/common/src/imbi/common/prompts/resolve.py
async def resolve(
    db: graph.Graph, ref: str
) -> tuple[models.Prompt, models.PromptVersion, str | None]:
    """Find the version a reference names.

    Returns:
        The prompt, the version, and the label used (``None`` when the
        reference named a version number).

    Raises:
        InvalidRef: The reference is not valid.
        PromptNotFound: The prompt, version, or a usable label does not
            exist.

    """
    namespace, slug, selector = parse_ref(ref)
    prompt, _latest = await fetch_prompt(db, namespace, slug)
    if selector is not None and selector.isascii() and selector.isdigit():
        return prompt, await fetch_version(db, prompt, int(selector)), None
    by_name = {label.name: label for label in prompt.labels}
    label = by_name.get(selector or '') or by_name.get(prompt.default_label)
    if label is None:
        raise PromptNotFound(
            f'Prompt {namespace}/{slug} has no label {selector!r} '
            f'and no default label {prompt.default_label!r}'
        )
    version = await fetch_version(db, prompt, label.version)
    return prompt, version, label.name

parse_ref

parse_ref(ref: str) -> tuple[str, str, str | None]

Split namespace/slug@label into its parts.

Raises:

Type Description
InvalidRef

The reference is not valid.

Source code in libraries/common/src/imbi/common/prompts/resolve.py
def parse_ref(ref: str) -> tuple[str, str, str | None]:
    """Split ``namespace/slug@label`` into its parts.

    Raises:
        InvalidRef: The reference is not valid.

    """
    match = REF_PATTERN.match(ref)
    if match is None:
        raise InvalidRef(
            f'Invalid prompt reference {ref!r}; expected '
            'namespace/slug, namespace/slug@label, or namespace/slug@n'
        )
    return match['namespace'], match['slug'], match['label']

PromptError

Bases: Exception

A reference cannot be resolved.

InvalidRef

Bases: PromptError

The reference is not namespace/slug[@label|@n].

PromptNotFound

Bases: PromptError

The prompt, version, or a usable label does not exist.

Rendering

render async

render(
    version: PromptVersion,
    variables: Mapping[str, object],
    providers: Mapping[str, Provider],
) -> RenderedPrompt

Render the system prompt and each message of version.

Raises:

Type Description
RenderError

The variables are not valid, the template fails or breaks a limit, the render takes too long, or the output is too large.

Source code in libraries/common/src/imbi/common/prompts/rendering.py
async def render(
    version: models.PromptVersion,
    variables: collections.abc.Mapping[str, object],
    providers: collections.abc.Mapping[str, Provider],
) -> RenderedPrompt:
    """Render the system prompt and each message of ``version``.

    Raises:
        RenderError: The variables are not valid, the template fails or
            breaks a limit, the render takes too long, or the output is
            too large.

    """
    sources = {'system': version.system} | {
        f'messages.{i}': m.content for i, m in enumerate(version.messages)
    }
    rendered = await _render_sources(version, variables, providers, sources)
    return RenderedPrompt(
        system=rendered['system'],
        messages=[
            models.PromptMessage(
                role=m.role, content=rendered[f'messages.{i}']
            )
            for i, m in enumerate(version.messages)
        ],
    )

render_decision async

render_decision(
    version: PromptVersion,
    variables: Mapping[str, object],
    providers: Mapping[str, Provider],
) -> RenderedDecision

Render the state and every question template of version.

Raises:

Type Description
RenderError

As :func:render.

Source code in libraries/common/src/imbi/common/prompts/rendering.py
async def render_decision(
    version: models.PromptVersion,
    variables: collections.abc.Mapping[str, object],
    providers: collections.abc.Mapping[str, Provider],
) -> RenderedDecision:
    """Render the state and every question template of ``version``.

    Raises:
        RenderError: As :func:`render`.

    """
    rendered = await _render_sources(
        version,
        variables,
        providers,
        decision_sources(version.state, version.questions),
    )
    questions: dict[str, models.DecisionQuestion] = {}
    for qid, question in version.questions.items():
        base = f'questions.{qid}'
        instructions = rendered[f'{base}.instructions']
        match question:
            case models.NoulQuestion():
                criteria = (
                    None
                    if question.criteria is None
                    else models.NoulCriteria(
                        true=rendered[f'{base}.criteria.true'],
                        false=rendered[f'{base}.criteria.false'],
                    )
                )
                questions[qid] = models.NoulQuestion(
                    instructions=instructions, criteria=criteria
                )
            case models.ChoiceQuestion():
                questions[qid] = models.ChoiceQuestion(
                    instructions=instructions,
                    criteria={
                        key: (
                            None
                            if text is None
                            else rendered[f'{base}.criteria.{key}']
                        )
                        for key, text in question.criteria.items()
                    },
                )
            case models.ScoreQuestion():
                questions[qid] = models.ScoreQuestion(
                    instructions=instructions,
                    criteria=[
                        rendered[f'{base}.criteria.{i}']
                        for i in range(len(question.criteria))
                    ],
                )
    return RenderedDecision(
        state=_state_value(rendered['state']), questions=questions
    )

check_syntax

check_syntax(version_fields: Mapping[str, str]) -> None

Parse each template so a broken version cannot be saved.

Parameters:

Name Type Description Default
version_fields Mapping[str, str]

Field label (system, messages[0]) to template source.

required

Raises:

Type Description
RenderError

A template does not parse, uses a macro, or nests loops too deep.

Source code in libraries/common/src/imbi/common/prompts/rendering.py
def check_syntax(
    version_fields: collections.abc.Mapping[str, str],
) -> None:
    """Parse each template so a broken version cannot be saved.

    Parameters:
        version_fields: Field label (``system``, ``messages[0]``) to
            template source.

    Raises:
        RenderError: A template does not parse, uses a macro, or nests
            loops too deep.

    """
    for label, source in version_fields.items():
        try:
            _parse(source)
        except jinja2.TemplateSyntaxError as e:
            raise RenderError(f'{label}: line {e.lineno}: {e.message}') from e
        except RenderError as e:
            raise RenderError(f'{label}: {e}') from e

validate_variables

validate_variables(
    schema: Mapping[str, PromptVariable],
    variables: Mapping[str, object],
) -> None

Check the caller's variables against the version's schema.

Raises:

Type Description
RenderError

A variable is unknown, missing, or the wrong type.

Source code in libraries/common/src/imbi/common/prompts/rendering.py
def validate_variables(
    schema: collections.abc.Mapping[str, models.PromptVariable],
    variables: collections.abc.Mapping[str, object],
) -> None:
    """Check the caller's variables against the version's schema.

    Raises:
        RenderError: A variable is unknown, missing, or the wrong type.

    """
    unknown = sorted(set(variables) - set(schema))
    if unknown:
        raise RenderError(f'Unknown variables: {", ".join(unknown)}')
    for name, spec in schema.items():
        if name not in variables:
            if spec.required:
                raise RenderError(f'Missing required variable {name!r}')
            continue
        value = variables[name]
        allowed = _TYPES[spec.type]
        # ``bool`` is an ``int`` in Python; do not accept it as a number.
        is_bool = isinstance(value, bool)
        if not isinstance(value, allowed) or (is_bool and spec.type != 'bool'):
            raise RenderError(f'Variable {name!r} must be of type {spec.type}')

RenderError

Bases: ValueError

The template or the variables cannot produce a prompt.

Worker pool

pool_lifespan async

pool_lifespan() -> collections.abc.AsyncIterator[None]

Start warm render workers with a service, and stop them after.

Source code in libraries/common/src/imbi/common/prompts/pool.py
@contextlib.asynccontextmanager
async def pool_lifespan() -> collections.abc.AsyncIterator[None]:
    """Start warm render workers with a service, and stop them after."""
    if not in_process():
        try:
            await get_pool().start()
        except Exception:  # rendering starts workers on demand instead
            LOGGER.exception('Could not start the prompt render workers')
    try:
        yield
    finally:
        await shutdown()

get_pool

get_pool() -> RenderPool

Return the pool for the running event loop.

Workers are tied to the loop that started them. A pool left by a loop that has ended (tests, TestClient portals) is killed and replaced.

Source code in libraries/common/src/imbi/common/prompts/pool.py
def get_pool() -> RenderPool:
    """Return the pool for the running event loop.

    Workers are tied to the loop that started them. A pool left by a
    loop that has ended (tests, ``TestClient`` portals) is killed and
    replaced.
    """
    global _current
    loop = asyncio.get_running_loop()
    if _current is not None and _current.loop is not loop:
        _current.abandon()
        _current = None
    if _current is None:
        _current = RenderPool()
    return _current

RenderPool

RenderPool(
    size: int | None = None, max_renders: int | None = None
)

Warm render workers bound to one event loop.

Source code in libraries/common/src/imbi/common/prompts/pool.py
def __init__(
    self,
    size: int | None = None,
    max_renders: int | None = None,
) -> None:
    self.size = size or _int_setting('IMBI_PROMPT_WORKERS', 2)
    self.max_renders = max_renders or _int_setting(
        'IMBI_PROMPT_WORKER_MAX_RENDERS', 500
    )
    self.loop = asyncio.get_running_loop()
    self._slots = asyncio.Semaphore(self.size)
    self._idle: list[_Worker] = []
    self._workers: set[_Worker] = set()
    self._closed = False

abandon

abandon() -> None

Kill every worker without the pool's event loop.

Source code in libraries/common/src/imbi/common/prompts/pool.py
def abandon(self) -> None:
    """Kill every worker without the pool's event loop."""
    self._closed = True
    for worker in list(self._workers):
        worker.abandon()
    self._workers.clear()
    self._idle.clear()

close async

close() -> None

Stop every worker.

Source code in libraries/common/src/imbi/common/prompts/pool.py
async def close(self) -> None:
    """Stop every worker."""
    self._closed = True
    self._idle.clear()
    await asyncio.gather(
        *(worker.close() for worker in list(self._workers)),
        return_exceptions=True,
    )
    self._workers.clear()

render_sources async

render_sources(
    schema: Mapping[str, PromptVariable],
    variables: Mapping[str, object],
    providers: Mapping[str, Provider],
    sources: Mapping[str, str],
) -> dict[str, str]

Render sources in a worker; see :func:.rendering.render.

Raises:

Type Description
RenderError

The render failed, timed out, or the worker stopped.

Source code in libraries/common/src/imbi/common/prompts/pool.py
async def render_sources(
    self,
    schema: collections.abc.Mapping[str, models.PromptVariable],
    variables: collections.abc.Mapping[str, object],
    providers: collections.abc.Mapping[str, rendering.Provider],
    sources: collections.abc.Mapping[str, str],
) -> dict[str, str]:
    """Render ``sources`` in a worker; see :func:`.rendering.render`.

    Raises:
        RenderError: The render failed, timed out, or the worker
            stopped.

    """
    if self._closed:
        raise rendering.RenderError('The render pool is closed')
    async with self._slots:
        worker = await self._take()
        healthy = False
        try:
            async with asyncio.timeout(
                rendering.RENDER_TIMEOUT + KILL_GRACE
            ):
                result = await worker.render(
                    schema, variables, providers, sources
                )
            healthy = True
            return result
        except TimeoutError as e:
            raise rendering.RenderError(
                f'Render took longer than {rendering.RENDER_TIMEOUT} '
                'seconds'
            ) from e
        except _WorkerFailed as e:
            LOGGER.warning('Prompt render worker failed: %s', e)
            raise rendering.RenderError(
                f'The render worker failed: {e}'
            ) from e
        except Exception:
            # A render error or a provider's own exception: the
            # worker finished the request and is still usable.
            healthy = True
            raise
        finally:
            await self._give_back(worker, healthy=healthy)

start async

start() -> None

Start the workers now instead of on first use.

Source code in libraries/common/src/imbi/common/prompts/pool.py
async def start(self) -> None:
    """Start the workers now instead of on first use."""
    missing = self.size - len(self._workers)
    results = await asyncio.gather(
        *(_Worker.start() for _ in range(max(missing, 0))),
        return_exceptions=True,
    )
    failure: BaseException | None = None
    for result in results:
        if isinstance(result, _Worker):
            self._workers.add(result)
            self._idle.append(result)
        else:
            failure = result
    if failure is not None:
        raise failure