mirror of
https://github.com/pydantic/pydantic-ai-harness.git
synced 2026-07-21 02:45:34 +00:00
* feat(logfire): add ManagedPrompt capability Back an agent's instructions with a Logfire-managed prompt, resolved once per run inside wrap_run so the selected label/version are attached as baggage to every child span. Lets prompts be iterated, versioned, and rolled out from the Logfire UI without redeploying, with a code default as the offline safety net. Lives in pydantic_ai_harness.logfire; adds a 'logfire' optional dependency extra. The test directory is named logfire_variables to avoid shadowing the third-party logfire package under pyright's tests execution-environment root. * fix: satisfy codespell on managed prompt comment * docs(logfire): make ManagedPrompt examples runnable instead of skipped The examples fall back to their code default with no Logfire backend and run clean under a mocked model, matching the main README's unmarked quick-start. * refactor(logfire): rename ManagedPrompt `prompt` param to `name` Logfire calls a managed prompt by its name, so `ManagedPrompt(name=...)` reads better than `ManagedPrompt(prompt=...)`. Also clarifies the `label` and `targeting_key` docstrings per review. * review fixes: cache framing, public imports, [spec] in logfire extra - README: prompt-cache trade-off section, notes on once-per-run resolution, label+version baggage semantics, and the targeting_context outer setter. Callout pointing at pydantic-ai#5107 so users know a first-party `Managed` capability is in flight. - Switch to the public `logfire.variables` import paths (was reaching into `logfire.variables.variable` / `.abstract`). - Pull `pydantic-ai-slim[spec]` from the `logfire` extra so `render_template=True` works without a separate install. - Warn when `logfire_instance` is passed alongside a prebuilt `Variable` (silently ignored before). - Scrub `code.lineno` from span snapshots and expand the volatile-attributes comment so the snapshot doesn't rot on line shifts. - Wrap the provider-backed test in a context manager that restores the module's baseline Logfire config in `finally`, so future tests don't inherit a provider. --------- Co-authored-by: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Bill Easton <williamseaston@gmail.com> Co-authored-by: David SF <david.sanchez@pydantic.dev>
211 lines
9.8 KiB
Python
211 lines
9.8 KiB
Python
"""Back an agent's instructions with a Logfire-managed prompt."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import warnings
|
|
from collections.abc import Callable, Mapping
|
|
from contextvars import ContextVar
|
|
from dataclasses import dataclass, field
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import logfire
|
|
from logfire.variables import Variable
|
|
from pydantic_ai import TemplateStr
|
|
from pydantic_ai.capabilities import AbstractCapability, CapabilityOrdering, Instrumentation
|
|
from pydantic_ai.tools import AgentDepsT, RunContext
|
|
|
|
if TYPE_CHECKING:
|
|
from logfire import Logfire
|
|
from logfire.variables import ResolvedVariable
|
|
from pydantic_ai.capabilities.abstract import WrapRunHandler
|
|
from pydantic_ai.run import AgentRunResult
|
|
|
|
|
|
# Logfire exposes a managed prompt with slug `<slug>` as a variable named `prompt__<slug>`,
|
|
# with hyphens replaced by underscores (see the Logfire prompt-management docs). `prompt__`
|
|
# is reserved for these system-managed prompts.
|
|
_PROMPT_VARIABLE_PREFIX = 'prompt__'
|
|
|
|
|
|
def _new_resolved_var() -> ContextVar[ResolvedVariable[str] | None]:
|
|
# `None` means nothing has been resolved for the active run.
|
|
return ContextVar('managed_prompt_resolved', default=None)
|
|
|
|
|
|
@dataclass
|
|
class ManagedPrompt(AbstractCapability[AgentDepsT]):
|
|
"""Back an agent's instructions with a Logfire-managed prompt.
|
|
|
|
**Prompt-cache trade-off:** the resolved value lands in the system instructions block, so any
|
|
Logfire-side change to the prompt (new version rollout, label flip, A/B targeting) invalidates
|
|
the provider's prompt cache for the affected runs. Pin a `label` (e.g. `'production'`) for the
|
|
cache-stable path; treat percentage rollouts and per-user targeting as opt-in cache cost. See
|
|
the README's "Prompt-cache trade-off" section for the full picture.
|
|
|
|
Pass the managed prompt name and a default value and the capability declares the backing
|
|
[managed variable](https://logfire.pydantic.dev/docs/reference/advanced/managed-variables/)
|
|
for you -- a name of `support_agent` resolves the variable `prompt__support_agent`, matching
|
|
the naming Logfire's [Prompt management](https://logfire.pydantic.dev/docs/reference/advanced/prompt-management/)
|
|
uses. You can iterate on the prompt from the Logfire UI -- versioned, labelled, and rolled
|
|
out -- without redeploying, while the code default keeps the agent working when no remote
|
|
value is available.
|
|
|
|
```python
|
|
import logfire
|
|
from pydantic_ai import Agent
|
|
|
|
from pydantic_ai_harness.logfire import ManagedPrompt
|
|
|
|
logfire.configure()
|
|
|
|
agent = Agent(
|
|
'openai:gpt-5',
|
|
capabilities=[
|
|
ManagedPrompt(
|
|
'support_agent',
|
|
default='You are a helpful customer support agent. Be friendly and concise.',
|
|
label='production',
|
|
)
|
|
],
|
|
)
|
|
result = agent.run_sync('My order never arrived.')
|
|
```
|
|
|
|
The prompt value is resolved **once per run**, inside the run's
|
|
[`wrap_run`][pydantic_ai.capabilities.AbstractCapability.wrap_run] hook, using the
|
|
[`ResolvedVariable`][logfire.variables.ResolvedVariable] as a context manager that stays open for the
|
|
whole run -- so the selected label and version are attached as baggage to every child span
|
|
of the agent run.
|
|
|
|
Declaring the same name more than once is fine -- each `ManagedPrompt` constructs its own
|
|
backing variable, so sharing a prompt across several agents just works. Pass an existing
|
|
[`logfire.variables.Variable`][logfire.variables.Variable] as `name` instead of a prompt name
|
|
when you want to use a variable you defined yourself (for example a `template_var`, or one
|
|
registered for [`variables_push`][logfire.Logfire.variables_push]).
|
|
"""
|
|
|
|
name: str | Variable[str]
|
|
"""The managed prompt name (declared as the variable `prompt__<name>`), or a pre-built `logfire.Variable`."""
|
|
|
|
default: str | None = None
|
|
"""Code-default prompt text. Required when `name` is a prompt name; ignored when `name` is a `Variable`."""
|
|
|
|
label: str | None = None
|
|
"""Explicit targeting label on the Logfire managed prompt to resolve (e.g. `'production'`).
|
|
When `None`, the targeting rules on the managed variable select the label."""
|
|
|
|
targeting_key: str | Callable[[RunContext[AgentDepsT]], str | None] | None = None
|
|
"""Stable key that seeds Logfire's deterministic rollout assignment -- the same key always
|
|
lands in the same percentage bucket, so a given user keeps the same label across runs.
|
|
Accepts a static value or a callable that derives it from the
|
|
[`RunContext`][pydantic_ai.tools.RunContext]. When `None`, Logfire falls back to its own
|
|
targeting context and then the active trace id."""
|
|
|
|
attributes: Mapping[str, Any] | Callable[[RunContext[AgentDepsT]], Mapping[str, Any] | None] | None = None
|
|
"""Attributes for condition-based targeting rules, or a callable that derives them
|
|
from the [`RunContext`][pydantic_ai.tools.RunContext]."""
|
|
|
|
render_template: bool = False
|
|
"""When `True`, render the resolved prompt as a Handlebars template against the agent's
|
|
`deps` (the same mechanism as [`TemplateStr`][pydantic_ai.TemplateStr]); `{{field}}` is
|
|
filled from `deps`. Requires `pydantic-handlebars` (install `pydantic-ai-slim[spec]`).
|
|
Defaults to `False`, so the resolved prompt is used verbatim."""
|
|
|
|
logfire_instance: Logfire | None = None
|
|
"""Logfire instance to resolve the variable on. When `None`, the global default instance
|
|
(the one backing the module-level [`logfire.var`][logfire.var]) is used. Ignored when
|
|
`name` is a `Variable`."""
|
|
|
|
_variable: Variable[str] = field(init=False, repr=False, compare=False)
|
|
"""The managed variable backing the prompt (declared from the slug, or the one passed in)."""
|
|
|
|
_resolved: ContextVar[ResolvedVariable[str] | None] = field(
|
|
default_factory=_new_resolved_var, init=False, repr=False, compare=False
|
|
)
|
|
"""Per-run resolution, isolated across concurrent runs via the context variable."""
|
|
|
|
def __post_init__(self) -> None:
|
|
if not isinstance(self.name, str):
|
|
if self.logfire_instance is not None:
|
|
warnings.warn(
|
|
'`logfire_instance` is ignored when `name` is a `Variable`; '
|
|
'the variable already carries its own Logfire instance.',
|
|
stacklevel=2,
|
|
)
|
|
self._variable = self.name
|
|
return
|
|
|
|
if self.default is None:
|
|
raise TypeError('`default` is required when `name` is a prompt name rather than a `Variable`.')
|
|
|
|
# Strip the prefix if the user accidentally passed it so we can still apply
|
|
# hyphen-to-underscore normalization, then re-add the prefix below.
|
|
name = self.name
|
|
if name.startswith(_PROMPT_VARIABLE_PREFIX):
|
|
warnings.warn(
|
|
f'The {_PROMPT_VARIABLE_PREFIX!r} prefix is added automatically; '
|
|
f'pass the bare prompt name rather than {name!r}.',
|
|
stacklevel=2,
|
|
)
|
|
name = name[len(_PROMPT_VARIABLE_PREFIX) :]
|
|
|
|
variable_name = f'{_PROMPT_VARIABLE_PREFIX}{name.replace("-", "_")}'
|
|
if not variable_name.isidentifier():
|
|
raise ValueError(
|
|
f'Prompt name {self.name!r} produces an invalid variable name {variable_name!r}; '
|
|
'names may only contain letters, digits, hyphens, and underscores.'
|
|
)
|
|
|
|
# Construct the variable directly (rather than via `logfire.var`) so redeclaring the
|
|
# same name is idempotent: `logfire.var` registers in a per-instance registry and raises
|
|
# on a duplicate name, which would break sharing one prompt across agents.
|
|
instance = self.logfire_instance if self.logfire_instance is not None else logfire.DEFAULT_LOGFIRE_INSTANCE
|
|
self._variable = Variable(variable_name, type=str, default=self.default, logfire_instance=instance)
|
|
|
|
@property
|
|
def resolved(self) -> ResolvedVariable[str] | None:
|
|
"""The prompt resolution for the active run, or `None` outside a run.
|
|
|
|
Exposes the full [`ResolvedVariable`][logfire.variables.ResolvedVariable] (`value`, `label`,
|
|
`version`, `reason`, ...) so callers can inspect which prompt version is in play.
|
|
"""
|
|
return self._resolved.get()
|
|
|
|
def get_ordering(self) -> CapabilityOrdering:
|
|
"""Run outermost so the prompt's baggage envelops the whole run, including the run span."""
|
|
return CapabilityOrdering(position='outermost', wraps=[Instrumentation])
|
|
|
|
def get_instructions(self) -> Callable[[RunContext[AgentDepsT]], str | None]:
|
|
"""Provide the resolved prompt to the agent's system prompt."""
|
|
|
|
def instructions(ctx: RunContext[AgentDepsT]) -> str | None:
|
|
resolved = self.resolved
|
|
if resolved is None:
|
|
# No active run -- contribute no instructions.
|
|
return None
|
|
if self.render_template:
|
|
return TemplateStr[AgentDepsT](resolved.value).render(ctx.deps)
|
|
return resolved.value
|
|
|
|
return instructions
|
|
|
|
async def wrap_run(self, ctx: RunContext[AgentDepsT], *, handler: WrapRunHandler) -> AgentRunResult[Any]:
|
|
"""Resolve the prompt once and keep its baggage active for the duration of the run."""
|
|
if callable(self.targeting_key):
|
|
targeting_key = self.targeting_key(ctx)
|
|
else:
|
|
targeting_key = self.targeting_key
|
|
|
|
if callable(self.attributes):
|
|
attributes = self.attributes(ctx)
|
|
else:
|
|
attributes = self.attributes
|
|
|
|
resolved = self._variable.get(targeting_key=targeting_key, attributes=attributes, label=self.label)
|
|
with resolved:
|
|
token = self._resolved.set(resolved)
|
|
try:
|
|
return await handler()
|
|
finally:
|
|
self._resolved.reset(token)
|