diff --git a/docs_v1/00-index.md b/docs_v1/00-index.md new file mode 100644 index 0000000..0d3ed4c --- /dev/null +++ b/docs_v1/00-index.md @@ -0,0 +1,121 @@ +# Amplifier Agent + +Amplifier Agent is a Python library for embedding an AI agent in your application. +It gives you a model with tools, a loop that runs until the task is done, and a +stream of events describing everything that happened along the way. + +What the agent is good for depends on the tools you give it. A filesystem and a +shell make it a coding agent. Your deployment API makes it a release agent. Your +internal services make it whatever those services do. + +```python +from amplifier_agent import AgentConfig, ProviderConfig, create_agent + +agent = await create_agent( + AgentConfig( + instructions="You are a careful engineer. Explain before you edit.", + provider=ProviderConfig(name="anthropic", model="claude-sonnet-5"), + ) +) + +session = await agent.create_session() +result = await session.run("Find the failing test in tests/ and fix it.") +print(result.reply) +``` + +## Why use it + +- **Bring your own model.** Name a provider and a model and the agent handles the + rest. Moving between Anthropic, OpenAI, Azure, and the others is a configuration + change, not a rewrite. +- **Give it your own tools.** Built-in tools cover the filesystem, shell, and web. + Beyond those, any Python function becomes a tool and any MCP server plugs in + alongside them. The model sees one flat set and does not know where each one + came from. +- **Teach it what it needs to know.** Skills package domain knowledge and + procedures the agent picks up when a task calls for them, so your instructions + stay short and the expertise arrives at the moment it is useful. +- **Decide what it is allowed to do.** Every tool call can route through your code + before it runs. Approve it, deny it, rewrite its arguments, or cancel the turn. +- **Watch it work.** A running turn emits typed events covering reasoning, replies, + tool calls, tool results, and token usage. Render them however you want, or + ignore them and await the final result. +- **Pick up where you left off.** Sessions persist to disk. Resume one tomorrow, + fork one to explore an alternative, or throw it away. + +## The pieces + +- **Agent** is created from an `AgentConfig`. It owns sessions and lives as long as + your application does. +- **Session** is a conversation with history. It runs one turn at a time and + persists between turns. +- **Turn** is one task, from your prompt to the agent's final reply. Await it for a + result, or iterate it for events. +- **Event** is everything that happens inside a turn, as it happens. +- **Tool** is what the agent can actually do. Built in, yours, or from an MCP server. +- **Approval** is your veto on a tool call before it runs. +- **Provider** is the model behind it all, plus how its credentials are resolved. + +Four calls carry the whole library: `create_agent`, `create_session`, and then +`run` or `stream`. Everything else describes what flows through them. + +## Streaming a turn + +`run` is the short version of `stream`. When you want to show progress rather than +wait for it, iterate instead of awaiting. + +```python +async for event in session.stream("Refactor the parser module."): + match event: + case MessageDelta(text=text): + print(text, end="", flush=True) + case ToolCall(name=name): + print(f"\n[{name}]") +``` + +Both paths run the same turn and produce the same result. + +## Where to go next + +- Install the library, and the CLI and SDKs if you want them, with + [Install](01-install.md). +- Build a working agent end to end with the [Quickstart](02-quickstart.md). +- Look up any `AgentConfig` field in [Configuration](03-configuration.md). +- Choose a provider and set up credentials in [Providers](06-providers/index.md). +- Read the complete public surface, one page per area, in + [Interface](05-interface/index.md). Start here if you are implementing against + Amplifier Agent rather than calling it. +- Reach the agent through the CLI, the HTTP face, or the TypeScript SDK in + [Surfaces](07-surfaces/index.md). + +## Amplifier Agent or Amplifier App CLI? + +Amplifier App CLI is a full application built on the same ecosystem, and it exposes +a much larger surface: bundles, behaviors, recipes, hooks, and swappable +orchestrators. Those are how you compose and reshape an agent from the outside. + +Use Amplifier App CLI when: + +- you want to assemble the agent yourself from bundles and modules +- you want to swap the orchestrator or attach hooks to the loop +- you are shipping modes, skills, or recipes to end users + +Use Amplifier Agent when: + +- you want an agent inside your application rather than an application around one +- you want the full agent without taking on responsibility for how it is built +- you want an interface that holds still while the internals keep moving + +The narrower surface is the point. Amplifier Agent keeps the following out of your +hands deliberately: + +- **Composition.** Bundles, mount plans, modules, and manifests decide how an agent + assembles itself. Amplifier Agent assembles itself. +- **The loop.** Orchestrators, hooks, and context management change the shape of + the agent's reasoning. You steer it with instructions, tools, and approvals. +- **Sub-agents.** The agent delegates when a task calls for it, but which + sub-agents exist is ours to define rather than yours to configure. Their work + arrives in your event stream as ordinary tool activity. +- **The prompt.** Prompt assembly and context-window management belong to the + agent. Instruction content goes in through `instructions`. +- **Model routing.** You name a model. The agent decides how to use it. diff --git a/docs_v1/01-install.md b/docs_v1/01-install.md new file mode 100644 index 0000000..843f71b --- /dev/null +++ b/docs_v1/01-install.md @@ -0,0 +1,127 @@ +# Install + +## Prerequisites + +**[uv](https://docs.astral.sh/uv/).** Everything below goes through it. + +```bash +# macOS and Linux +curl -LsSf https://astral.sh/uv/install.sh | sh +``` + +```pwsh +# Windows +powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" +``` + +**`git` on your `PATH`, at run time as well as install time.** The agent fetches +components it needs the first time it runs. On Windows, +[Git for Windows](https://git-scm.com/download/win) covers this and also provides +the shell the agent's built-in shell tool looks for. Windows additionally needs +long paths enabled: + +```bash +git config --global core.longpaths true +``` + +You do not need to install Python. uv downloads a suitable interpreter if your +system does not already have one. + +## The library + +```bash +uv add git+https://github.com/microsoft/amplifier-agent +``` + +That gives you the `amplifier_agent` package, which is everything the rest of +these docs describe. Pin a release rather than tracking the default branch: + +```bash +uv add git+https://github.com/microsoft/amplifier-agent --tag v0.17.0 +``` + +Available tags are listed at +. + +## The CLI + +The same distribution provides the `amplifier-agent` command. Install it as a +standalone tool when you want the command on your `PATH` without adding the +library to a project: + +```bash +uv tool install git+https://github.com/microsoft/amplifier-agent +``` + +Pin a release by putting the tag in the URL: + +```bash +uv tool install git+https://github.com/microsoft/amplifier-agent@v0.17.0 +``` + +There is also an installer script that resolves the latest release and installs +it in one step: + +```bash +curl -fsSL https://raw.githubusercontent.com/microsoft/amplifier-agent/main/install.sh | bash -s -- --yes +``` + +The script takes `--tag ` to pin a version, `--no-prime` to skip warming the +cache, and `--yes` to skip the confirmation prompt. Flags go after `-s --`, since +everything before that is consumed by `bash` itself. + +If a host application spawns `amplifier-agent` as a subprocess, install it as the +user that runs the host process. A tool installed by `root` while the service +runs unprivileged is not on the `PATH` the subprocess inherits. + +## The TypeScript SDK + +```bash +pnpm add amplifier-agent-ts +``` + +The SDK does not bundle the agent. Install both. See +[Surfaces](07-surfaces/typescript.md) for what it covers. + +## Verify + +```bash +amplifier-agent doctor # environment, providers, paths +amplifier-agent version # release and contract versions +``` + +`doctor` reports which providers have credentials it can resolve, which is +usually the fastest answer to "why does my agent say it cannot find a model." + +Both are compositions of library calls, formatted for a terminal. The same +answers from Python: + +```python +import amplifier_agent +from amplifier_agent import list_providers + +print(amplifier_agent.__version__, amplifier_agent.contract_version) + +for status in await list_providers(): + print(status.descriptor.name, status.available, status.credential_source) +``` + +## Update and remove + +```bash +uv tool upgrade amplifier-agent +``` + +A tool installed from a pinned tag stays on it. To move to a different release, +install again with the new tag. + +To remove everything, including stored sessions and credentials: + +```bash +uv tool uninstall amplifier-agent +rm -rf ~/.amplifier-agent +``` + +## Next + +Build a working agent in the [Quickstart](02-quickstart.md). diff --git a/docs_v1/02-quickstart.md b/docs_v1/02-quickstart.md new file mode 100644 index 0000000..f57efdd --- /dev/null +++ b/docs_v1/02-quickstart.md @@ -0,0 +1,188 @@ +# Quickstart + +Build an agent, give it a tool, and watch it work. + +## Install and set a credential + +```bash +uv add git+https://github.com/microsoft/amplifier-agent +``` + +Set a credential for whichever provider you plan to use: + +```bash +export OPENAI_API_KEY=sk-... +export ANTHROPIC_API_KEY=sk-ant-... +``` + +Every supported provider works the same way, and the examples below name the one +they use. [Providers](06-providers/index.md) covers the full list and how +credentials are resolved. + +## Your first agent + +```python +import asyncio +from amplifier_agent import AgentConfig, ProviderConfig, create_agent + + +async def main(): + agent = await create_agent( + AgentConfig( + provider=ProviderConfig(name="anthropic", model="claude-sonnet-5"), + ) + ) + async with agent: + session = await agent.create_session() + result = await session.run("What files are in this directory?") + print(result.reply) + + +asyncio.run(main()) +``` + +Save that as `main.py` and run it: + +```bash +uv run main.py +``` + +`uv run` executes the script against your project's dependencies, so there is no +virtual environment to activate. It also checks that the environment matches your +lockfile before every run. + +The agent already has tools. It reads the directory with its built-in filesystem +tool and answers from what it found, without you wiring anything up. + +`create_agent` is where validation happens. Constructing an `AgentConfig` is +inert, so a bad provider name or a missing credential surfaces here rather than +at import time. + +## Give it your own tool + +Any function becomes a tool. Describe it, hand the agent a JSON schema for its +arguments, and return whatever the model should see. + +```python +from amplifier_agent import HostTool, ToolResult, ToolsConfig + + +async def open_ticket(args: dict) -> ToolResult: + ticket_id = tracker.create(title=args["title"], body=args.get("body", "")) + return ToolResult(content=f"Opened {ticket_id}") + + +agent = await create_agent( + AgentConfig( + provider=ProviderConfig(name="anthropic", model="claude-sonnet-5"), + instructions="File a ticket whenever you find a bug you cannot fix.", + tools=ToolsConfig( + host_tools=[ + HostTool( + name="open_ticket", + description="File a bug report in the issue tracker.", + input_schema={ + "type": "object", + "properties": { + "title": {"type": "string"}, + "body": {"type": "string"}, + }, + "required": ["title"], + }, + handler=open_ticket, + ) + ] + ), + ) +) +``` + +The model sees `open_ticket` alongside the built-in tools and cannot tell the +difference. Your handler runs in your process, with your imports and your +credentials. + +## Watch it work + +`run` waits for the final reply. `stream` yields events as they happen, which is +what you want behind any interface a person is looking at. + +```python +from amplifier_agent import MessageDelta, ToolCall, ToolResultEvent + +async for event in session.stream("Refactor the parser and run the tests."): + match event: + case MessageDelta(text=text): + print(text, end="", flush=True) + case ToolCall(name=name, arguments=args): + print(f"\n-> {name}({args})") + case ToolResultEvent(result=ToolResult(is_error=True, content=content)): + print(f"\n!! {content}") +``` + +Both paths run the same turn. `run` is `stream` consumed to completion. + +## Approve what it does + +Without an approval handler, tool calls that need approval are denied. Supply one +and you decide, call by call. + +```python +from amplifier_agent import ApprovalRequest, ApprovalResponse + + +async def approve(request: ApprovalRequest) -> ApprovalResponse: + print(f"{request.tool_name}: {request.arguments}") + answer = input("allow? [y/N] ") + if answer.lower() == "y": + return ApprovalResponse(action="allow") + return ApprovalResponse(action="deny", reason="operator declined") + + +agent = await create_agent( + AgentConfig( + provider=ProviderConfig(name="anthropic", model="claude-sonnet-5"), + approvals=approve, + ) +) +``` + +Denying fails that one call and lets the turn continue. The agent sees the +failure and works around it. To stop the whole turn, return +`ApprovalResponse(action="cancel")`. + +## Keep the conversation going + +A session holds its history. Consecutive turns on the same session see each +other. + +```python +session = await agent.create_session() + +await session.run("Read src/parser.py and summarize it.") +await session.run("Now write tests for the third function you described.") +``` + +Sessions persist, so you can come back later: + +```python +session = await agent.create_session() +session_id = session.id +# ... a day passes, a new process starts ... +session = await agent.resume_session(session_id) +``` + +Forking gives you a copy that shares the history up to that point and diverges +from there, which is how you try two approaches without losing the first. + +```python +alternative = await session.fork() +``` + +## Where to go next + +- Every configuration field, in [Configuration](03-configuration.md). +- Recording sessions and shipping them somewhere you can query, in + [Context Intelligence](04-context-intelligence.md). +- Credentials and model selection per provider, in + [Providers](06-providers/index.md). +- The complete surface, one page per area, in [Interface](05-interface/index.md). diff --git a/docs_v1/03-configuration.md b/docs_v1/03-configuration.md new file mode 100644 index 0000000..b680cf0 --- /dev/null +++ b/docs_v1/03-configuration.md @@ -0,0 +1,250 @@ +# Configuration + +`AgentConfig` is the only input to `create_agent`. Everything about a running +agent is declared here, as plain data. + +```python +@dataclass(frozen=True) +class AgentConfig: + provider: ProviderConfig + workspace: str | None = None + cwd: str | None = None + instructions: str | Instructions | None = None + tools: ToolsConfig = field(default_factory=ToolsConfig) + skills: SkillsConfig = field(default_factory=SkillsConfig) + approvals: ApprovalHandler | None = None + mcp_servers: list[McpServerConfig] = field(default_factory=list) + storage: StorageConfig | None = None + context_intelligence: ContextIntelligenceConfig | None = None +``` + +Constructing an `AgentConfig` performs no I/O. It contacts no provider, reads no +credential store, and opens no connection. Validation and credential resolution +happen inside `create_agent`, which raises an `AgentError` with a `config/*` code +when something does not check out. A bad provider name fails where you called +`create_agent`, not where you built the config. + +This page owns the shape of every field. Four topics are large enough to own +their own pages, and this page defers to them rather than repeating them: + +- Choosing a provider and resolving its credentials is + [Providers](06-providers/index.md). +- Recording and uploading sessions is + [Context Intelligence](04-context-intelligence.md). +- Where session state lands on disk is [Storage](05-interface/storage.md). +- What a `HostTool` handler receives and returns is + [Tools](05-interface/tools.md). + +## provider + +The model behind the agent. + +```python +@dataclass(frozen=True) +class ProviderConfig: + name: str + model: str | None = None + model_roles: Mapping[str, str] | None = None + credentials: Mapping[str, str] | None = None + options: Mapping[str, object] | None = None + max_retries: int = 3 +``` + +- **`name`** selects the provider. `create_agent` raises + `config/unknown_provider` if it is not one the agent knows. +- **`model`** names the model. Omit it to take the provider's default. +- **`model_roles`** maps role names to models, for the roles the agent uses + internally. Pointing the `fast` role at a cheaper model sends the agent's + small internal work there while the main work stays on `model`. +- **`credentials`** supplies credentials directly. Omit it and the agent + resolves them from the environment and its credential store. +- **`options`** passes provider-specific settings straight through without + interpretation. +- **`max_retries`** bounds how many times the agent retries a transient provider + failure before surfacing it. Set it to `0` when your own caller already + retries. See [Providers](05-interface/providers.md#retries). + +## workspace and cwd + +```python +AgentConfig( + provider=ProviderConfig(name="anthropic"), + cwd="/srv/checkouts/api", + workspace="api", +) +``` + +**`cwd`** is the directory the agent operates in. Its file tools resolve relative +paths against it and its shell tool starts there. When omitted, it is the process +working directory at the moment you call `create_agent`. + +**`workspace`** partitions session listings and storage. Two agents on the same +workspace see each other's sessions in `list_sessions`; two agents on different +workspaces do not. When omitted, it is derived from `cwd`, so separate checkouts +get separate session histories without you asking for it. + +Both can be overridden for a single session through `create_session`. + +## instructions + +```python +AgentConfig( + provider=ProviderConfig(name="anthropic"), + instructions="Prefer small commits. Never edit files under vendor/.", +) +``` + +A bare string is added after the agent's own instructions. Your guidance sits on +top of the agent's, which continues to govern tool use and output conventions. + +To supply the entire instruction set instead: + +```python +from amplifier_agent import Instructions + +AgentConfig( + provider=ProviderConfig(name="anthropic"), + instructions=Instructions(text=my_full_prompt, mode="replace"), +) +``` + +Under `replace` you own instruction quality completely. The guidance the agent +relies on is gone, and behavior that depended on it changes. Tool declarations +survive, because describing available tools to the model is protocol rather than +instruction. + +`create_session` takes its own `instructions` that override the agent-level value +for one session. + +## tools + +```python +@dataclass(frozen=True) +class ToolsConfig: + host_tools: list[HostTool] = field(default_factory=list) + allow: list[str] | None = None + deny: list[str] = field(default_factory=list) +``` + +- **`host_tools`** are your own functions, exposed to the model alongside the + built-ins. +- **`allow`** restricts the tool set to exactly these names. `None` leaves the + default set intact. +- **`deny`** removes names regardless of `allow` or the default set. + +Deny wins over allow. A name in both is denied. + +```python +ToolsConfig(deny=["shell"]) # everything but the shell +ToolsConfig(allow=["read_file", "grep"]) # read-only +``` + +Denied tools are never described to the model, so it does not attempt them and +does not narrate working around them. + +## skills + +```python +@dataclass(frozen=True) +class SkillsConfig: + sources: list[str] = field(default_factory=list) + show_catalog: bool = False + max_catalog_entries: int = 50 +``` + +- **`sources`** are additional places to find skills. Each entry is a git URL, a + local directory, or a bundle reference. Sources extend the built-in set rather + than replacing it. +- **`show_catalog`** puts every discovered skill's name and description into the + model's context so it can choose skills on its own. Off by default, which means + skills are invoked explicitly. +- **`max_catalog_entries`** caps that catalog. + +```python +SkillsConfig(sources=["git+https://github.com/my-org/team-skills@main#subdirectory=skills"]) +``` + +See [Skills](05-interface/skills.md). + +## approvals + +A callable that gates tool calls before they run. + +```python +approvals: Callable[[ApprovalRequest], Awaitable[ApprovalResponse]] | None +``` + +Leaving it `None` denies every call that would need approval. There is no mode in +which an unattended agent silently gets more permission than an attended one. +[Approvals](05-interface/approvals.md) covers the request and response types and +the three actions. + +## mcp_servers + +```python +@dataclass(frozen=True) +class McpServerConfig: + name: str + transport: Literal["stdio", "http"] + command: str | None = None + args: list[str] | None = None + env: Mapping[str, str] | None = None + url: str | None = None + headers: Mapping[str, str] | None = None +``` + +Each entry is one MCP server the agent connects to. `command`, `args`, and `env` +configure a `stdio` transport; `url` and `headers` configure an `http` one. + +```python +McpServerConfig( + name="github", + transport="stdio", + command="npx", + args=["-y", "@modelcontextprotocol/server-github"], + env={"GITHUB_TOKEN": token}, +) +``` + +Tools from these servers join the same flat set the model sees. They are subject +to `allow` and `deny` like any other. + +## storage + +```python +@dataclass(frozen=True) +class StorageConfig: + root: Path | None = None + persist: bool = True +``` + +- **`root`** is the directory session state is written under. `None` uses the + default location for the current user. +- **`persist`** controls whether sessions outlive the agent. With `persist=False` + nothing is written to disk, and `resume_session` finds nothing that is not + still live in memory. + +## context_intelligence + +```python +@dataclass(frozen=True) +class ContextIntelligenceConfig: + destinations: Mapping[str, Destination] = field(default_factory=dict) +``` + +Recording is on whenever storage is. `destinations` names servers those records +are also forwarded to, keyed by a name that identifies each one in logs. With no +destinations, recording stays local. To turn recording off entirely, set +`StorageConfig(persist=False)`. + +See [Context Intelligence](04-context-intelligence.md). + +## Errors from create_agent + +- **`config/invalid`** the configuration failed validation. +- **`config/unknown_provider`** the named provider is not available. +- **`config/unknown_model`** the named model is not available for that provider. +- **`config/missing_credentials`** no credential could be resolved. + +[Errors](05-interface/errors.md) has the full registry and the rule for which +failures raise and which arrive as events. diff --git a/docs_v1/04-context-intelligence.md b/docs_v1/04-context-intelligence.md new file mode 100644 index 0000000..32cba16 --- /dev/null +++ b/docs_v1/04-context-intelligence.md @@ -0,0 +1,267 @@ +# Context Intelligence + +Every session records what it did. Context Intelligence is that recording, plus +the option to forward it to a server that turns many recordings into something +you can query. + +Locally it costs you nothing to set up. It is already on. + +``` +session runs + -> every event is appended to the session's event log on disk + -> forwarded to each configured destination + -> the server builds a queryable graph across every session it receives +``` + +The local log is the durable copy and is written first. Forwarding is opt-in, +best effort, and never a condition for a turn to succeed. + +## Why you would want it + +A single session is legible from its transcript. A thousand sessions are not. +Once recordings land on a server you can ask questions that span them: + +- Which tool fails most often, and what arguments does it fail on? +- How much did last week cost, split by model and by project? +- When an agent delegates, how deep does the chain go and where does it stall? +- Which sessions hit a context compaction, and what did they lose? + +None of that is answerable from one session directory, which is the whole reason +the upload path exists. + +## What gets captured + +Every event the agent emits, in the order it emitted them. That is the same +registry `stream` yields, listed in [Events](05-interface/events.md): the turn +boundaries, replies and reasoning, tool calls and their results, approvals, +usage, and errors. + +Each line of the log is one record with four keys: + +```json +{ + "event": "tool/call", + "workspace": "-home-alice-repos-api", + "timestamp": "2026-08-27T16:27:01.263670+00:00", + "data": {"session_id": "63cc3feb-...", "turn_id": "...", "tool_name": "read_file", "...": "..."} +} +``` + +- **`event`** is the event name. +- **`workspace`** is the workspace the session belongs to. +- **`timestamp`** is ISO-8601 with an offset. +- **`data`** is the event payload. `session_id` and `turn_id` live in here, along + with everything specific to that event type. + +Records are appended in emission order and never rewritten. + +### Treat the log as sensitive + +The log holds what the agent actually saw and did, which includes full prompts, +complete model responses, and every tool argument and result. If a secret passed +through a tool call, it is in the log. If a customer record was read into +context, it is in the log. + +Two consequences worth deciding about before you turn on forwarding. Your +destination inherits the sensitivity of the sessions you point at it. And the +local directory deserves the same file permissions you would give a credential +store. + +## Where it lives + +Recordings sit in the session directory, alongside conversation state. + +``` +//sessions// + session.json what this session is + messages.jsonl conversation state + context-intelligence/ + events.jsonl the recording + metadata.json the recording's own session record +``` + +`` is `StorageConfig.root`, defaulting to +`~/.amplifier-agent/state/workspaces`. +`` is the agent's workspace. [Storage](05-interface/storage.md) +specifies the layout in full. + +The `context-intelligence/` directory is deliberate rather than incidental. +Every tool that reads recordings finds them by that exact path shape, so a +session directory produced here is readable by the existing Context Intelligence +tooling without adaptation. + +The two record files carry different format identities on purpose: + +``` +session.json format "amplifier-agent-session" +context-intelligence/metadata.json format "context-intelligence" +``` + +Readers on both sides check format and version before parsing anything. Two +identities means each tool correctly refuses the file that is not its own, +rather than parsing it into nonsense. + +`events.jsonl` is observational. Resuming a session reads `messages.jsonl` and +never needs the event log, so a truncated, deleted, or unreadable recording +costs you history and nothing else. + +Setting `StorageConfig(persist=False)` writes nothing at all, recordings +included. That is the way to turn capture off. + +## Forwarding to a server + +Add a destination. + +```python +from amplifier_agent import ( + AgentConfig, + ContextIntelligenceConfig, + Destination, + ProviderConfig, + create_agent, +) + +agent = await create_agent( + AgentConfig( + provider=ProviderConfig(name="anthropic", model="claude-sonnet-5"), + context_intelligence=ContextIntelligenceConfig( + destinations={ + "team": Destination( + url="https://ci.example.com", + api_key=os.environ["CI_API_KEY"], + ), + }, + ), + ) +) +``` + +```python +@dataclass(frozen=True) +class ContextIntelligenceConfig: + destinations: Mapping[str, Destination] = field(default_factory=dict) + + +@dataclass(frozen=True) +class Destination: + url: str + api_key: str | None = None + auth_mode: Literal["static", "entra"] = "static" + auth_resource: str | None = None +``` + +- **`url`** is the server's base URL. +- **`api_key`** is the bearer token, required under `auth_mode="static"`. +- **`auth_mode`** selects how requests are authenticated. +- **`auth_resource`** is the Entra audience, required under `auth_mode="entra"`. + +The mapping key names the destination in logs and diagnostics. + +Sessions go to every destination in the mapping. Two entries mean two copies, +which is how you feed a team server and a personal one at the same time without +choosing between them. + +An empty mapping, or `context_intelligence=None`, leaves recording local. + +## Authentication + +**`auth_mode="static"`**, the default, sends `Authorization: Bearer `. +Simple, and right for a server you run and hand out keys for. + +```python +Destination(url="https://ci.example.com", api_key=key) +``` + +**`auth_mode="entra"`** acquires a Microsoft Entra token and sends that instead. +The credential comes from the ambient environment, so the same configuration +works for a developer signed in locally and for a service running under a managed +identity, with no code change between them. + +```python +Destination( + url="https://ci.corp.example.com", + auth_mode="entra", + auth_resource="api://8f3c1e7a-...", +) +``` + +A destination reaching a server that only accepts static keys uses +`auth_mode="static"`. Each destination chooses independently, so a mixed fleet is +fine. + +Tokens are cached and refreshed ahead of expiry. Identity is resolved once per +process, so switching accounts takes a new process. + +A destination whose credentials do not validate is dropped when the agent starts, +loudly, and the others keep working. Local recording is unaffected either way. + +## What the server receives + +One record per request: + +``` +POST /events +Authorization: Bearer +Content-Type: application/json + +{ + "event": "tool/call", + "workspace": "-home-alice-repos-api", + "working_dir": "/home/alice/repos/api", + "idempotency_key": "aci-event-v1:", + "data": { ... } +} +``` + +The idempotency key is derived from the record's content, so a retry after an +ambiguous failure is safe. The server suppresses the duplicate rather than +recording the event twice. + +## When delivery fails + +The design assumption is that the network is unreliable and the log is not. + +- **The local log is written before anything is sent.** A failed upload never + touches it, so nothing is lost by a server being down. +- **Delivery is asynchronous.** Turns never block on the network. Records queue + and a background worker drains the queue. +- **Transient failures retry** with backoff, in place, so ordering holds. + Timeouts, connection failures, rate limits, and server errors are all + transient. +- **Permanent failures are skipped** and reported rather than retried forever. A + malformed record or a rejected credential does not stall everything behind it. +- **Sustained failure is escalated.** A destination that has been failing + continuously is reported at error level rather than staying quiet, because a + silent upload path that has been dead for two days is worse than a loud one. +- **Closing an agent drains what is queued,** within a bound. Records still + undelivered when that bound expires are reported with a count. They remain in + the local log. + +If a destination was unreachable for a stretch, the local logs are the recovery +path. They are complete, and they can be replayed to the server afterward. + +## Querying what you captured + +The server builds a property graph. Sessions, tool calls, and events are nodes; +forks, tool ownership, and event membership are edges. + +You query it with Cypher. + +```cypher +MATCH (s:Session {workspace: $workspace})-[:HAS_TOOL_CALL]->(t:ToolCall) +WHERE t.status = 'error' +RETURN t.tool_name, count(*) AS failures +ORDER BY failures DESC +``` + +Large payloads are offloaded to a blob store and replaced in the graph with a +`ci-blob://` reference, so a query that touches a session with a huge prompt +returns quickly instead of returning the prompt. + +The server, its graph model, and its query interface are documented at +[microsoft/amplifier-context-intelligence](https://github.com/microsoft/amplifier-context-intelligence). + +## Next + +- Where session state lives, in [Storage](05-interface/storage.md). +- The events you are recording, in [Events](05-interface/events.md). diff --git a/docs_v1/05-interface/agent.md b/docs_v1/05-interface/agent.md new file mode 100644 index 0000000..d23830e --- /dev/null +++ b/docs_v1/05-interface/agent.md @@ -0,0 +1,127 @@ +# Agent + +An `Agent` is what `create_agent` returns. It owns sessions, holds the resolved +provider, and lives as long as your application does. + +```python +async def create_agent(config: AgentConfig) -> Agent: ... +``` + +Everything the agent needs comes from the [`AgentConfig`](../03-configuration.md) +you pass. There is no second setup call and no mutable settings afterward. To +change how an agent behaves, create another one. + +## Creating an agent + +```python +agent = await create_agent( + AgentConfig(provider=ProviderConfig(name="anthropic", model="claude-sonnet-5")) +) +``` + +`create_agent` is where the configuration stops being inert. It validates the +config, resolves provider credentials, and prepares the tool set. Anything wrong +with the configuration surfaces here, as an `AgentError` with a `config/*` code, +rather than partway through a turn. + +Credentials resolve once per `create_agent` call and are not cached across calls, +so a rotated key takes effect on the next call without restarting the process. + +## Interface + +```python +class Agent: + async def create_session( + self, + *, + session_id: str | None = None, + workspace: str | None = None, + instructions: str | Instructions | None = None, + ) -> Session: ... + + async def resume_session(self, session_id: str) -> Session: ... + + async def list_sessions(self, *, workspace: str | None = None) -> list[SessionInfo]: ... + + async def delete_session(self, session_id: str) -> None: ... + + async def list_skills(self) -> list[SkillInfo]: ... + + async def close(self) -> None: ... + + async def __aenter__(self) -> "Agent": ... + async def __aexit__(self, *exc: object) -> None: ... +``` + +The four session methods are described in [Sessions](sessions.md). +`list_skills` is described in [Skills](skills.md). It is on the agent rather than +module-level because what is discoverable depends on the `SkillsConfig.sources` +this agent resolved. + +## Lifetime + +An agent moves through three states. + +``` +constructed -> ready -> closed +``` + +It is `ready` the moment `create_agent` returns, and stays that way until you +close it. `close` releases provider connections, MCP server connections, and +anything else the agent opened. It is idempotent, so closing twice is not an +error. + +Use it as a context manager when the scope is clear: + +```python +async with await create_agent(config) as agent: + session = await agent.create_session() + await session.run("...") +``` + +After close, `create_session` raises `session/closed`. Sessions already open are +closed with the agent. + +## Concurrency + +Sessions on one agent are independent and run concurrently. + +```python +results = await asyncio.gather( + session_a.run("Review the parser."), + session_b.run("Review the lexer."), +) +``` + +Within a single session, one turn runs at a time. Starting a second turn on a +session that is already running one raises `session/busy`, which is retryable. +If you want two turns at once, use two sessions. + +## Versions + +```python +import amplifier_agent + +amplifier_agent.__version__ # the release you installed +amplifier_agent.contract_version # the version of this interface it implements +``` + +Both are module-level, so you can read them without constructing an agent. That +matters because the reason to check a version is usually that something is not +working, which is exactly when `create_agent` is failing. + +`contract_version` is the one worth branching on. It changes independently of the +release version, and it is what tells you whether the interface you are coding +against is the one you have. + +## Errors + +- **`config/invalid`** the configuration failed validation. +- **`config/unknown_provider`** the named provider is not registered. +- **`config/unknown_model`** the named model is not available for that provider. +- **`config/missing_credentials`** a required credential could not be resolved. + `details` names the unresolved fields and the environment variable that would + satisfy each. +- **`session/closed`** `create_session` was called on a closed agent. + +See [Errors](errors.md) for the full registry. diff --git a/docs_v1/05-interface/approvals.md b/docs_v1/05-interface/approvals.md new file mode 100644 index 0000000..78501f2 --- /dev/null +++ b/docs_v1/05-interface/approvals.md @@ -0,0 +1,133 @@ +# Approvals + +An approval handler is your veto on a tool call before it runs. It is the one +place where your code decides whether the agent gets to act. + +```python +ApprovalHandler = Callable[[ApprovalRequest], Awaitable[ApprovalResponse]] +``` + +Set it on [`AgentConfig.approvals`](../03-configuration.md#approvals). + +## Without a handler, calls are denied + +Leaving `approvals=None` denies every call that would need approval. It does not +allow them, and there is no setting that makes an unattended agent more permitted +than an attended one. + +This matters for headless deployments. An agent running in CI with no handler +does not quietly gain permission because nobody is watching. If you want it to +proceed, supply a handler that says so, and the decision is recorded in your code +rather than implied by an absence. + +## The types + +```python +@dataclass(frozen=True) +class ApprovalRequest: + approval_id: str + session_id: str + turn_id: str + kind: str + tool_name: str + arguments: Mapping[str, object] + timeout_ms: int + + +@dataclass(frozen=True) +class ApprovalResponse: + action: Literal["allow", "deny", "cancel"] + reason: str | None = None + arguments: Mapping[str, object] | None = None +``` + +`kind` names what is being approved. `tool_name` and `arguments` are the call +itself. `timeout_ms` is how long the agent waits before treating the request as +unanswered. + +## The three actions + +**`allow`** lets the call proceed. + +```python +return ApprovalResponse(action="allow") +``` + +You may rewrite the arguments on the way through, which is how you narrow a call +rather than refusing it outright: + +```python +if request.tool_name == "write_file": + path = confine_to_workspace(request.arguments["path"]) + return ApprovalResponse(action="allow", arguments={**request.arguments, "path": path}) +``` + +**`deny`** fails that one call. The turn continues, the model sees a +`ToolResult(is_error=True)`, and a recoverable `tool/denied` error event is +emitted. The agent typically works around the refusal. + +```python +return ApprovalResponse(action="deny", reason="writes outside the workspace are not allowed") +``` + +The `reason` reaches the model. A specific reason produces a better recovery than +a vague one, because the model can use it to choose a different approach. + +**`cancel`** ends the whole turn, with `stop_reason="cancelled"`. + +```python +return ApprovalResponse(action="cancel", reason="operator stopped the run") +``` + +Use `deny` to refuse an action. Use `cancel` to stop the agent. + +## A worked handler + +```python +AUTO_ALLOW = {"read_file", "grep", "list_directory"} + + +async def approve(request: ApprovalRequest) -> ApprovalResponse: + if request.tool_name in AUTO_ALLOW: + return ApprovalResponse(action="allow") + + decision = await ask_operator(request.tool_name, request.arguments) + if decision == "yes": + return ApprovalResponse(action="allow") + if decision == "stop": + return ApprovalResponse(action="cancel", reason="operator stopped the run") + return ApprovalResponse(action="deny", reason="operator declined") +``` + +The handler is awaited inside the turn, so a slow handler is a slow turn. If you +are prompting a person, `timeout_ms` is the bound on how long the agent waits. + +## Timeouts + +A handler that does not respond within `timeout_ms` produces +`tool/approval_timeout`. Like a denial, it is recoverable: the call fails, the +model sees the failure, and the turn continues. It is never treated as an +approval. + +## Approvals and the event stream + +Two events accompany every approval, but they are for observation only. + +``` +approval/requested approval_id, tool_name, timeout_ms +approval/resolved approval_id, action +``` + +The event stream is output. It cannot carry a decision back, which is why +approvals are a callback rather than a message on the stream. If you are +rendering an interface, use the events to show what is being asked and your +handler to answer it, correlating the two by `approval_id`. + +## Errors + +- **`tool/denied`** the handler denied the call. Recoverable, never raised. +- **`tool/approval_timeout`** the handler did not respond in time. Recoverable, + never raised. + +A `cancel` action ends the turn with `stop_reason="cancelled"` and correlates +with `turn/cancelled`. See [Errors](errors.md). diff --git a/docs_v1/05-interface/errors.md b/docs_v1/05-interface/errors.md new file mode 100644 index 0000000..e35739a --- /dev/null +++ b/docs_v1/05-interface/errors.md @@ -0,0 +1,135 @@ +# Errors + +Every failure is an `AgentError` carrying a code from a closed set. + +```python +@dataclass(frozen=True) +class AgentError(Exception): + code: ErrorCode + message: str + retryable: bool + details: Mapping[str, object] | None = None +``` + +`ErrorCode` is a string enum with one member per row of the registry below. It +compares equal to its own string, so `err.code == "session/busy"` works without +importing the enum. + +Codes take the form `group/name`, except `internal`, which is bare. The group is +part of the interface, so branching on the prefix is supported. + +## Registry + +``` +config/invalid no the configuration failed validation +config/unknown_provider no the named provider is not registered +config/unknown_model no the model is not available for that provider +config/missing_credentials no no credential could be resolved + +session/not_found no the session id does not exist +session/busy yes a turn is already running on the session +session/closed no the session or its agent has been closed + +turn/cancelled no cancelled by the host or by an approval +turn/max_iterations no the turn hit the iteration limit +turn/context_overflow no the conversation exceeded the context window + +provider/error no the provider rejected the request +provider/unavailable yes the provider could not be reached +provider/rate_limited yes the provider rate-limited the request + +tool/failed no a tool handler raised an unhandled exception +tool/denied no an approval handler denied the call +tool/approval_timeout no the approval handler did not respond in time + +storage/unavailable yes session storage could not be read or written + +internal no an unexpected failure inside the agent +``` + +The middle column is `retryable`. + +The six groups above, plus the bare `internal` code, are the whole set. A failure that fits none of the listed codes +is reported as `internal` with context in `details`, rather than under a new +group. + +## Raised or emitted + +Where an error appears depends on when it happened, and it determines where your +handling goes. + +- **Before a turn starts**, it is raised. From `run` directly, or from the first + `__anext__` of `stream`. +- **During a turn**, it is emitted as an `error` event and carried on the + `TurnResult` of the terminal `turn/completed` event. + +`run` re-raises a carried error only when `stop_reason` is `error` and `reply` is +`None`. A turn that hit a problem but still produced a reply returns normally, +with the failure on `TurnResult.error`. + +```python +try: + result = await session.run(prompt) +except AgentError as err: + # the turn never started, or it ended in failure with no reply + ... +else: + if result.error is not None: + # the turn produced a reply but something went wrong along the way + ... +``` + +`tool/denied` and `tool/approval_timeout` are always emitted and never raised. +The turn continues and the model sees a `ToolResult(is_error=True)`. + +## retryable and recoverable + +Two different questions, and mixing them up produces either a retry loop that +never succeeds or a failure surfaced for something the agent already handled. + +- **`AgentError.retryable`** asks whether making the same request again might + work. +- **`ErrorEvent.recoverable`** asks whether this turn continues past the event. + +A denied tool call is recoverable, because the turn goes on, and not retryable, +because the same call would be denied again. + +## Retrying + +Four codes are retryable: `session/busy`, `provider/unavailable`, +`provider/rate_limited`, and `storage/unavailable`. All four describe a condition +that can clear on its own, so back off and try again rather than surfacing them +as terminal. + +Two of them have already been retried by the time you see them. +`provider/unavailable` and `provider/rate_limited` are raised only after the +agent exhausted its own backoff, bounded by +[`ProviderConfig.max_retries`](providers.md#retries). They are still retryable, +but they mean the fast retries are spent, so wait longer than you otherwise would +and treat a repeat as an outage rather than noise. + +```python +for attempt in range(3): + try: + return await session.run(prompt) + except AgentError as err: + if not err.retryable: + raise + await asyncio.sleep(2 ** attempt) +raise +``` + +Everything else is not retryable. Repeating the same request unchanged will fail +the same way, so change the request, the configuration, or the state first. + +## details + +`details` carries structured context specific to the code. For +`config/missing_credentials` it names the unresolved fields and the environment +variable that would satisfy each, which is usually the whole answer. + +A resolved credential never appears in `details`, in a message, or in any event. + +To find the surrounding activity for an error that happened during a turn, use +its `turn_id`, which every event and every `TurnResult` carries. See the +[event log](../04-context-intelligence.md). diff --git a/docs_v1/05-interface/events.md b/docs_v1/05-interface/events.md new file mode 100644 index 0000000..80d44ec --- /dev/null +++ b/docs_v1/05-interface/events.md @@ -0,0 +1,175 @@ +# Events + +The event stream is the only way to observe a turn while it runs. `stream` yields +these for one turn; `run` consumes the same sequence internally and returns only +the final result. + +Every event carries `session_id`, `turn_id`, and a `type` string fixed to its row +in the registry. Every `TurnStarted` carries `type == "turn/started"`, and so on. + +## The registry + +``` +turn/started TurnStarted prompt +thinking/delta ThinkingDelta text +thinking/final ThinkingFinal text +message/delta MessageDelta text +message/final MessageFinal text +tool/call ToolCall tool_call_id, name, arguments, source +tool/result ToolResultEvent tool_call_id, name, result, duration_ms +approval/requested ApprovalRequested approval_id, tool_name, timeout_ms +approval/resolved ApprovalResolved approval_id, action +usage UsageEvent usage +error ErrorEvent error, recoverable +turn/completed TurnCompleted result +``` + +## Types + +```python +@dataclass(frozen=True) +class Event: + session_id: str + turn_id: str + type: str + + +@dataclass(frozen=True) +class TurnStarted(Event): + prompt: str + + +@dataclass(frozen=True) +class ThinkingDelta(Event): + text: str + + +@dataclass(frozen=True) +class ThinkingFinal(Event): + text: str + + +@dataclass(frozen=True) +class MessageDelta(Event): + text: str + + +@dataclass(frozen=True) +class MessageFinal(Event): + text: str + + +@dataclass(frozen=True) +class ToolCall(Event): + tool_call_id: str + name: str + arguments: Mapping[str, object] + source: Literal["builtin", "host", "mcp"] + + +@dataclass(frozen=True) +class ToolResultEvent(Event): + tool_call_id: str + name: str + result: ToolResult + duration_ms: int + + +@dataclass(frozen=True) +class ApprovalRequested(Event): + approval_id: str + tool_name: str + timeout_ms: int + + +@dataclass(frozen=True) +class ApprovalResolved(Event): + approval_id: str + action: Literal["allow", "deny", "cancel"] + + +@dataclass(frozen=True) +class UsageEvent(Event): + usage: Usage + + +@dataclass(frozen=True) +class ErrorEvent(Event): + error: AgentError + recoverable: bool + + +@dataclass(frozen=True) +class TurnCompleted(Event): + result: TurnResult +``` + +## Ordering + +These hold for every turn, and code that renders the stream can rely on them. + +- **`turn/started` is first, exactly once.** +- **`turn/completed` is last, exactly once**, including when the turn fails or is + cancelled. A turn always terminates with it. +- **Every `tool/call` is followed by exactly one `tool/result`** carrying the same + `tool_call_id`, unless the turn ends first. +- **`message/final` carries the complete text** of the `message/delta` run before + it. Render deltas and discard the final, or ignore deltas and render only + finals. Doing both duplicates the text. +- **The agent emits only these types.** Wrappers translate them; they do not + invent new ones. + +## Deltas and finals + +Streaming text arrives twice, once incrementally and once whole. Which you use +depends on what you are building. + +```python +# A live interface: render as it arrives +case MessageDelta(text=text): + print(text, end="", flush=True) + +# A log or a transcript: take the complete text once +case MessageFinal(text=text): + transcript.append(text) +``` + +`thinking/delta` and `thinking/final` work the same way for the agent's +reasoning. + +## Recoverable and unrecoverable errors + +`ErrorEvent.recoverable` says whether the turn continues past the event. + +```python +case ErrorEvent(error=err, recoverable=True): + log.warning("recovered: %s", err.code) # turn continues +case ErrorEvent(error=err, recoverable=False): + show_failure(err) # turn/completed follows +``` + +A denied tool call is recoverable: that one call fails, the model sees the +failure, and the turn goes on. An unrecoverable error is followed by +`turn/completed` with `stop_reason="error"`. + +Surface unrecoverable errors. Recoverable ones are usually noise in a user +interface and detail in a log. + +Note that `recoverable` and `AgentError.retryable` answer different questions. +`recoverable` is about this turn continuing. `retryable` is about whether making +the same request again might work. See [Errors](errors.md). + +## A typical turn + +One valid ordering for a turn that calls a single tool. It is not the only one. + +``` +turn/started +message/delta ... +tool/call +tool/result +message/delta ... +message/final +usage +turn/completed +``` diff --git a/docs_v1/05-interface/index.md b/docs_v1/05-interface/index.md new file mode 100644 index 0000000..a4e9382 --- /dev/null +++ b/docs_v1/05-interface/index.md @@ -0,0 +1,154 @@ +# Interface + +Every public name in the library is on this page, and each is described in full +on the page that owns it. Everything else in the package is internal. + +## How it fits together + +Four roles, and the boundaries between them are what the rest of these pages +describe. + +- **Host.** Your application. It supplies configuration, host tools, and an + approval handler, and it decides what reaches a person. +- **Agent.** What you get back from `create_agent`. It owns its own composition, + its own reasoning loop, and how it uses the provider you named. +- **Provider.** The model behind the agent, plus how its credentials resolve. +- **Tools.** What the agent can do. Built in, supplied by you, or reached + through an MCP server. + +The division of labor is consistent throughout. The host owns policy and +presentation. The agent owns its own construction. Configuration is inert data. +The event stream is the only way to observe a turn while it runs. + +## Core objects + +```python +agent = await create_agent(config) # AgentConfig -> Agent +session = await agent.create_session() # Agent -> Session +result = await session.run(prompt) # Session -> TurnResult +# or +async for event in session.stream(prompt): + ... +``` + +An `Agent` lives as long as your application. A `Session` is a conversation with +history and runs one turn at a time. A turn is one task, driven either by +awaiting `run` for a result or by iterating `stream` for events. `run` is +`stream` consumed to completion, so both take the same path and produce the same +outcome. + +## Pages + +- [Agent](agent.md) creating an agent, its lifetime, and what it reports about + itself. +- [Sessions](sessions.md) identity, history, resuming, and forking. +- [Turns](turns.md) running, streaming, cancelling, and accounting for usage. +- [Events](events.md) the event registry and the ordering rules that hold for + every turn. +- [Tools](tools.md) built-in, host, and MCP tools, and how the model sees each. +- [Skills](skills.md) packaged knowledge the agent loads when a task calls for + it. +- [Approvals](approvals.md) the approval handler and the three resolutions. +- [Providers](providers.md) descriptors, credential resolution, and model roles. +- [Storage](storage.md) the on-disk session layout and persistence. +- [Errors](errors.md) `AgentError` and the closed set of error codes. + +## The surface + +**Entry point** + +``` +create_agent(config: AgentConfig) -> Agent +``` + +**Core objects** + +``` +Agent create_session, resume_session, list_sessions, delete_session, + list_skills, close +Session run, stream, cancel, fork, close, id, workspace, history, + usage +``` + +**Configuration** + +``` +AgentConfig +Instructions +ProviderConfig +ToolsConfig +SkillsConfig +McpServerConfig +StorageConfig +ContextIntelligenceConfig +Destination +``` + +**Tools and approvals** + +``` +HostTool +ToolResult +ApprovalHandler +ApprovalRequest +ApprovalResponse +``` + +**Turns and results** + +``` +Attachment +Message +TurnResult +Usage +SessionInfo +``` + +**Module-level** + +``` +__version__ +contract_version +``` + +**Events** + +``` +Event +TurnStarted TurnCompleted +ThinkingDelta ThinkingFinal +MessageDelta MessageFinal +ToolCall ToolResultEvent +ApprovalRequested ApprovalResolved +UsageEvent ErrorEvent +``` + +**Providers** + +``` +ProviderDescriptor +ModelDescriptor +CredentialField +ProviderStatus +list_providers() +list_models(provider: str) +``` + +**Skills** + +``` +SkillInfo +``` + +**Storage** + +``` +SessionRecord +``` + +**Errors** + +``` +AgentError +ErrorCode +``` diff --git a/docs_v1/05-interface/providers.md b/docs_v1/05-interface/providers.md new file mode 100644 index 0000000..d680837 --- /dev/null +++ b/docs_v1/05-interface/providers.md @@ -0,0 +1,178 @@ +# Providers + +A provider declares its own credential fields and default model, and that +declaration is what the agent uses. There is no table inside the agent to fall +out of date, and discovery tells you what is actually available rather than what +was true when the agent shipped. + +This page covers the mechanism. For the credentials and models of a specific +provider, see [Providers](../06-providers/index.md). + +## Selecting one + +```python +@dataclass(frozen=True) +class ProviderConfig: + name: str + model: str | None = None + model_roles: Mapping[str, str] | None = None + credentials: Mapping[str, str] | None = None + options: Mapping[str, object] | None = None + max_retries: int = 3 +``` + +`name` refers to a registered provider, and `create_agent` raises +`config/unknown_provider` when nothing is registered under it. `model` takes the +provider's `default_model` when omitted. + +`options` is an opaque pass-through. The agent does not interpret, validate, or +rewrite it, which is what lets a provider gain a setting without any change to +the agent or to this interface. + +## Descriptors + +```python +@dataclass(frozen=True) +class CredentialField: + name: str + display_name: str + env_var: str | None = None + secret: bool = True + required: bool = True + default: str | None = None + + +@dataclass(frozen=True) +class ProviderDescriptor: + name: str + display_name: str + credentials: list[CredentialField] + default_model: str | None = None + + +@dataclass(frozen=True) +class ModelDescriptor: + id: str + display_name: str + context_window: int + max_output_tokens: int +``` + +Because the provider supplies these at runtime, they cannot drift from what the +provider actually does. There is no table inside the agent to fall out of date. + +## Credential resolution + +Resolution runs per `CredentialField`, first match wins: + +``` +1. ProviderConfig.credentials[field.name] +2. the environment variable named by field.env_var +3. the credential store +4. field.default +``` + +Environment before store follows the `gh` and `aws` convention, so a one-off +export can point a single run at a different key without disturbing what you have +saved. + +A required field that resolves to nothing fails `create_agent` with +`config/missing_credentials`. The error names each unresolved field and the +environment variable that would satisfy it, so the message tells you what to +export. + +Three guarantees worth relying on: + +- **Credentials resolve once per `create_agent` call** and are not cached across + calls. A rotated key takes effect on the next call, with no process restart. +- **A resolved credential never appears** in an event, an error message, + `AgentError.details`, or a descriptor. +- **`options` cannot override a credential.** Resolved credentials are reapplied + after the options overlay, so a stray key in `options` cannot redirect + authentication. + +## Model roles + +`model_roles` maps a role name to a model. The agent uses roles for its own +internal work, and pointing one at a cheaper model moves that work without +touching the model doing your main task. + +```python +ProviderConfig( + name="anthropic", + model="claude-opus-5", + model_roles={"fast": "claude-sonnet-5"}, +) +``` + +Unmapped roles fall back to `model`. + +## Retries + +The agent retries transient provider failures itself, with exponential backoff +and jitter, up to `max_retries` attempts per request. Timeouts, connection +failures, `429`, and `5xx` are transient. A rate limit carrying a `Retry-After` +waits that long rather than guessing. + +This matters for reading the error codes, because it changes what they mean: + +``` +provider/unavailable the provider stayed unreachable across every attempt +provider/rate_limited the provider stayed rate-limited across every attempt +provider/error the provider rejected the request; not retried +``` + +`provider/unavailable` and `provider/rate_limited` are still marked `retryable`, +because waiting longer than the agent is willing to wait can genuinely clear +them. But they no longer mean "try once more" the way they would from a raw HTTP +client. The agent already did. Back off substantially further than you would +otherwise, and treat a second occurrence as a real outage rather than noise. + +Retries happen inside a turn and are invisible in the event stream. The turn +takes longer; nothing else changes. They do not consume turn iterations, and a +retried request is billed by the provider only for the attempts it served. + +`max_retries=0` disables the behavior and surfaces the first failure, which is +what you want when your own caller is already retrying and you would otherwise +multiply the two. + +## Discovery + +```python +async def list_providers() -> list[ProviderStatus]: ... +async def list_models(provider: str) -> list[ModelDescriptor]: ... + + +@dataclass(frozen=True) +class ProviderStatus: + descriptor: ProviderDescriptor + available: bool + credential_source: Literal["config", "environment", "store", "default", "unresolved"] + unresolved: list[str] +``` + +Both are module-level functions and neither needs an `Agent`, because you call +them to decide what to put in an `AgentConfig` in the first place. + +```python +for status in await list_providers(): + if status.available: + print(status.descriptor.name, "via", status.credential_source) + else: + print(status.descriptor.name, "missing", status.unresolved) +``` + +`available` is `True` when every required credential field resolves. +`list_models` may contact the provider, so it can fail with +`provider/unavailable` or `config/missing_credentials`. + +## Errors + +- **`config/unknown_provider`** nothing is registered under that name. +- **`config/unknown_model`** the model is not available for that provider. +- **`config/missing_credentials`** a required credential did not resolve. +- **`provider/error`** the provider rejected the request. +- **`provider/unavailable`** the provider could not be reached. Retryable. +- **`provider/rate_limited`** the provider rate-limited the request. Retryable. + +See [Errors](errors.md) for the full registry. diff --git a/docs_v1/05-interface/sessions.md b/docs_v1/05-interface/sessions.md new file mode 100644 index 0000000..ff99192 --- /dev/null +++ b/docs_v1/05-interface/sessions.md @@ -0,0 +1,207 @@ +# Sessions + +A session is a conversation. It accumulates turns, holds their history, and when +persistence is on it survives the process that created it. + +Sessions are created from an `Agent`. Turn execution is on this same object but +is described in [Turns](turns.md). + +## Interface + +On `Agent`: + +```python +async def create_session( + self, + *, + session_id: str | None = None, + workspace: str | None = None, + instructions: str | Instructions | None = None, +) -> Session: ... + +async def resume_session(self, session_id: str) -> Session: ... + +async def list_sessions(self, *, workspace: str | None = None) -> list[SessionInfo]: ... + +async def delete_session(self, session_id: str) -> None: ... +``` + +On `Session`: + +```python +class Session: + id: str + workspace: str + + async def history(self) -> list[Message]: ... + + async def usage(self) -> Usage: ... + + async def fork(self, *, session_id: str | None = None) -> Session: ... + + async def close(self) -> None: ... + + async def __aenter__(self) -> "Session": ... + async def __aexit__(self, *exc: object) -> None: ... +``` + +## Identity + +A session is identified by the pair `(workspace, session_id)`. The id is unique +within its workspace, not globally. + +`workspace` defaults to a value derived from the agent's `cwd`, so separate +checkouts get separate session histories without you asking. Pass `workspace` on +`create_session` to override it for one session. + +When `create_session` omits `session_id`, one is generated and available on +`Session.id`. Supply your own when you want session ids to match identifiers your +application already has. + +```python +session = await agent.create_session(session_id=f"ticket-{ticket.id}") +``` + +## History + +Turns on the same session see each other. + +```python +session = await agent.create_session() +await session.run("Read src/parser.py and summarize it.") +await session.run("Now write tests for the third function you described.") +``` + +The second turn knows what the first one found. This holds for the lifetime of +the `Session` object regardless of whether persistence is enabled. + +`history()` returns the conversation as a list of `Message`. It is a read of what +the agent is working from, not a handle for editing it. + +```python +@dataclass(frozen=True) +class Message: + role: Literal["system", "user", "assistant", "tool"] + content: str +``` + +`content` is the message text, which is what you need to render a conversation. +The complete record, including tool calls and the agent's reasoning, is on disk +in `messages.jsonl`. See [Storage](storage.md). + +## Usage + +`usage()` returns the session's running total, accumulated across every turn it +has run. `TurnResult.usage` is one turn; this is all of them. + +```python +total = await session.usage() +print(total.cost_usd, "over", len(await session.history()), "messages") +``` + +The fields carry the same meaning and the same overlap rules as on a single turn. +See [Turns](turns.md#usage). Two differences follow from it being cumulative: + +- **`cost_usd` is `None` if any turn's cost was unknown.** A partial total is + worse than no total, because it looks authoritative and under-reports. +- **`model` is the model of the most recent turn.** A session that changed models + is not summarized by one name, so use the per-turn `TurnResult.usage` when you + need spend attributed by model. + +A resumed session resumes its totals. Forking copies the source's totals to the +fork, so the two diverge from the fork point the same way their histories do. + +## Per-session instructions + +`create_session` accepts `instructions` that override the agent-level value for +that session only. Append and replace semantics are the same as on +[`AgentConfig`](../03-configuration.md#instructions). + +```python +reviewer = await agent.create_session( + instructions="You are reviewing, not editing. Never modify a file." +) +``` + +This is how one agent serves several roles without constructing several agents. + +## Resuming + +```python +session = await agent.resume_session("ticket-4417") +``` + +`resume_session` reconstructs a session from persisted state and raises +`session/not_found` when there is nothing under that id. What gets reconstructed +is covered in [Storage](storage.md). + +With `StorageConfig(persist=False)`, nothing is written, so `resume_session` +finds only sessions still live in the current process. + +## Listing and deleting + +```python +for info in await agent.list_sessions(): + print(info.id, info.turn_count, info.updated_at) +``` + +`list_sessions` returns `SessionInfo` for the sessions visible to the agent, +filtered to `workspace` when you pass one. It reads session records only, never +conversation state, so listing a thousand sessions does not load a thousand +transcripts. + +```python +@dataclass(frozen=True) +class SessionInfo: + id: str + workspace: str + created_at: datetime + updated_at: datetime + turn_count: int + usage: Usage +``` + +`usage` is the same cumulative total `Session.usage()` returns, carried on the +record rather than computed on read. That is what keeps "what did we spend last +month" a listing instead of a thousand transcript replays. + +`delete_session` removes the session and everything recorded under it. A +subsequent `resume_session` for that id raises `session/not_found`. + +## Forking + +`fork` creates a new session seeded with a copy of the source session's history +at the time of the call. + +```python +alternative = await session.fork() +await alternative.run("Try the other approach instead.") +``` + +The source is unaffected by the fork and by any later turn on it. The two +diverge from the fork point and never rejoin. This is how you explore a second +approach without losing the first, and how you branch one expensive setup +conversation into several cheap continuations. + +## Closing + +`close` releases the session. It is idempotent. Closing an agent closes its +sessions. + +```python +async with await agent.create_session() as session: + await session.run("...") +``` + +Turn methods on a closed session raise `session/closed`. + +## Errors + +- **`session/not_found`** no session exists under that id, raised from + `resume_session` and `delete_session`. +- **`session/closed`** the session or its agent has been closed. +- **`session/busy`** a turn is already running on this session. Retryable. +- **`storage/unavailable`** persisted state could not be read or written while + `persist` is on. Retryable. + +See [Errors](errors.md) for the full registry. diff --git a/docs_v1/05-interface/skills.md b/docs_v1/05-interface/skills.md new file mode 100644 index 0000000..c704844 --- /dev/null +++ b/docs_v1/05-interface/skills.md @@ -0,0 +1,152 @@ +# Skills + +A skill is packaged knowledge or a packaged procedure the agent picks up when a +task calls for it. Instructions that would otherwise bloat every prompt live in a +skill and cost nothing until they are used. + +``` +skills/ + code-review/ + SKILL.md name, description, and the body + checklist.md optional companion files +``` + +## Why they are not just longer instructions + +Skills load in three stages, and only the first is always present. + +- **Name and description**, roughly a hundred tokens per skill, is what the agent + sees when deciding whether a skill applies. +- **The body**, one to five thousand tokens, loads only when the agent decides it + applies. +- **Companion files** cost nothing until the agent reads one. + +So a hundred skills is a hundred descriptions, not a hundred bodies. That is the +whole reason the mechanism exists: expertise available at the moment of need +rather than resident in every prompt. + +## Configuration + +```python +@dataclass(frozen=True) +class SkillsConfig: + sources: list[str] = field(default_factory=list) + show_catalog: bool = False + max_catalog_entries: int = 50 +``` + +- **`sources`** are additional places to find skills. Each entry is a git URL, a + local directory path, or a bundle reference. +- **`show_catalog`** puts the name and description of every discovered skill into + the model's context so it can choose skills itself. +- **`max_catalog_entries`** caps that catalog. + +```python +AgentConfig( + provider=ProviderConfig(name="anthropic"), + skills=SkillsConfig( + sources=[ + "git+https://github.com/my-org/team-skills@main#subdirectory=skills", + "/srv/skills", + ], + ), +) +``` + +`sources` extends the built-in set. It does not replace it, and there is no way +to remove a built-in skill through configuration. + +## Invoking a skill + +With `show_catalog=False`, the default, skills are invoked explicitly. Start a +prompt with the skill sigil and it routes straight to the loader instead of to +the model: + +```python +await session.run("!amplifier:skill code-review src/parser.py") +``` + +The sigil is honored only at the start of a user prompt. Anywhere else it is +ordinary text, because a skill executes tools and tool execution should not be +triggerable by something the model wrote. + +With `show_catalog=True`, the agent sees the catalog and loads skills on its own +when it judges one relevant. + +## Skills fail open + +An unknown skill name, or a loader that errors, does not fail the turn. The +prompt runs as ordinary text instead. + +A mistyped skill name costs you the skill, not the turn. + +## Discovery and shadowing + +Skills are found in order, and the first match for a given name wins: + +``` +1. built-in skills +2. sources, in the order you listed them +3. .amplifier/skills relative to the working directory +4. ~/.amplifier/skills +``` + +A name defined in more than one place is shadowed rather than merged. The winner +runs and the losers are reported alongside it, so a skill quietly overriding +another is visible instead of mysterious. + +One consequence worth knowing: a source that is a remote URL is invocable but +does not appear in listings, because listing does not fetch. + +## Listing + +```python +async def list_skills(self) -> list[SkillInfo]: ... + + +@dataclass(frozen=True) +class SkillInfo: + name: str + description: str + source: str + shadowed: list[str] +``` + +`list_skills` is on `Agent` rather than module-level, because what is discoverable +depends on `SkillsConfig.sources` and there is nothing to list until an agent +resolves them. + +```python +for skill in await agent.list_skills(): + print(skill.name, skill.description) + for loser in skill.shadowed: + print(" shadowed:", loser) +``` + +- **`source`** is where the winning definition was found. +- **`shadowed`** is every other place the same name was defined, in discovery + order, and is empty when there was no collision. + +Listing reads only each skill's name and description, never a body or a +companion file. A remote source is not fetched, so skills reachable only through +one are invocable but absent here. + +`shadowed` is on the listing rather than in a log because a skill quietly +overriding another is the failure worth seeing, and it is only visible when +something puts the winner and the losers side by side. + +## Forked skills + +A skill whose frontmatter declares `context: fork` runs as an isolated +sub-session rather than loading into the current conversation. It gets its own +context window, does its work, and returns only its result. + +That keeps a large piece of work from consuming the conversation it was invoked +from. From your side nothing changes: it is still a tool call in the event +stream, and its activity arrives as ordinary tool events. + +## Events + +Skill activity reaches you as `tool/call` and `tool/result` for the loader, like +any other tool. There is no separate skill event type in the +[registry](events.md). diff --git a/docs_v1/05-interface/storage.md b/docs_v1/05-interface/storage.md new file mode 100644 index 0000000..a58cf85 --- /dev/null +++ b/docs_v1/05-interface/storage.md @@ -0,0 +1,110 @@ +# Storage + +Session state lives on disk so a conversation survives the process that started +it. This page covers where it goes and what survives. + +```python +@dataclass(frozen=True) +class StorageConfig: + root: Path | None = None + persist: bool = True +``` + +## Layout + +``` +//sessions// + session.json the session record + messages.jsonl conversation state, one message per line + context-intelligence/ + events.jsonl the event log + metadata.json the event log's own record +``` + +`` is `StorageConfig.root`, defaulting to `~/.amplifier-agent/state/workspaces`. + +`` comes from the agent's workspace, which is derived from `cwd` when +you do not set it. Derivation resolves the path to absolute, replaces `/` and `\` +with `-`, drops `:`, and prefixes `-` if the result does not already start with +one. So `/home/alice/repos/api` becomes `-home-alice-repos-api`. + +A session id containing `/`, `\`, or `..` is rejected with `config/invalid`. + +## What each file is for + +**`session.json`** is the session record. `list_sessions` reads these and nothing +else, which is why listing a thousand sessions does not load a thousand +transcripts. + +```python +@dataclass(frozen=True) +class SessionRecord: + format: str # "amplifier-agent-session" + version: str # "1" + session_id: str + workspace: str + parent_id: str | None + working_dir: str + started_at: datetime + last_event_at: datetime + ended_at: datetime | None + status: Literal["running", "completed", "failed", "cancelled"] + turn_count: int + usage: Usage +``` + +`format` and `version` come first because a reader checks them before +interpreting anything else. A record it does not recognize is refused rather than +parsed into something plausible and wrong. + +**`messages.jsonl`** is the conversation. It is the only resume-critical file: +`resume_session` reads it and needs nothing else. It is written durably as each +turn completes, so a process that dies between turns loses no completed turn. + +**`context-intelligence/`** is the recording. It is observational, never required +to resume, and covered in [Context Intelligence](../04-context-intelligence.md). + +## Persistence + +`persist=True`, the default, writes conversation state durably as each turn +completes. Sessions outlive the agent and can be resumed later. + +`persist=False` writes nothing under this layout. Sessions exist only in memory +for the life of the `Session` object, and `resume_session` raises +`session/not_found` for anything not still live in the process. Use it for +throwaway work, for tests, and anywhere a transcript on disk is a liability. + +`delete_session` removes the whole session directory. + +Nothing outside a session directory is part of this interface. The layout inside +one is what other tools can rely on. + +## Reading a session directory yourself + +The layout is stable enough to read directly, which is often the simplest way to +build reporting or auditing on top of an agent. + +```python +import json +from pathlib import Path + +session_dir = root / workspace / "sessions" / session_id + +record = json.loads((session_dir / "session.json").read_text()) +assert record["format"] == "amplifier-agent-session" and record["version"] == "1" + +messages = [json.loads(line) for line in (session_dir / "messages.jsonl").read_text().splitlines()] +``` + +Check `format` and `version` before you parse. That check is the reason the fields +exist, and it is what lets the layout change later without your reader silently +misinterpreting a file it does not understand. + +## Errors + +- **`storage/unavailable`** session state could not be read or written while + `persist` is on. Retryable. +- **`config/invalid`** a session id contained a path separator or `..`. +- **`session/not_found`** no session exists under that id. + +See [Errors](errors.md) for the full registry. diff --git a/docs_v1/05-interface/tools.md b/docs_v1/05-interface/tools.md new file mode 100644 index 0000000..1aeb4db --- /dev/null +++ b/docs_v1/05-interface/tools.md @@ -0,0 +1,139 @@ +# Tools + +Tools are how the agent acts beyond generating text. Every call resolves to one +of three sources: + +- **`builtin`** tools the agent ships with, covering the filesystem, shell, and + web. +- **`host`** tools you supply as Python callables. +- **`mcp`** tools proxied from an MCP server you configured. + +All three present the same interface to the model. It sees one flat set of named, +described, schema-typed tools and cannot tell which source a tool came from. The +`source` field on a `tool/call` event is the only discriminator, and it is for +you, not the model. + +## Host tools + +```python +@dataclass(frozen=True) +class HostTool: + name: str + description: str + input_schema: Mapping[str, object] + handler: Callable[[Mapping[str, object]], Awaitable[ToolResult]] + + +@dataclass(frozen=True) +class ToolResult: + content: str + is_error: bool = False + details: Mapping[str, object] | None = None +``` + +- **`name`** is what the model calls. It is unique across the combined builtin, + host, and MCP set. +- **`description`** is shown to the model. It is the main thing determining + whether the tool gets used correctly, so write it for the model rather than for + a code reviewer. +- **`input_schema`** is a JSON Schema mapping describing the arguments. +- **`handler`** is an async callable. The agent awaits it with the call's + arguments and expects a `ToolResult` back. + +```python +async def open_ticket(args: Mapping[str, object]) -> ToolResult: + ticket_id = await tracker.create(title=args["title"]) + return ToolResult(content=f"Opened {ticket_id}", details={"id": ticket_id}) + + +ToolsConfig(host_tools=[ + HostTool( + name="open_ticket", + description="File a bug report in the issue tracker.", + input_schema={ + "type": "object", + "properties": {"title": {"type": "string"}}, + "required": ["title"], + }, + handler=open_ticket, + ) +]) +``` + +`content` is what the model sees. `details` is structured data for your own code, +carried on the `tool/result` event and not guaranteed to reach the model. Put the +human-readable answer in `content` and the machine-readable one in `details`. + +There is no yield-and-resume protocol. The handler returns or it raises. + +## MCP tools + +Servers are configured on [`AgentConfig.mcp_servers`](../03-configuration.md#mcp_servers). +Their tools join the same flat set and are subject to the same filtering. + +## Allow and deny + +```python +ToolsConfig(deny=["shell"]) # everything except the shell +ToolsConfig(allow=["read_file", "grep"]) # read-only +``` + +- **`allow=None`** leaves the default set intact. +- **`allow=[...]`** restricts the set to those names. +- **`deny`** always wins. A name in both is denied. + +Filtering applies to names across all three sources, so a deny list is the way to +remove an MCP tool you do not want as easily as a built-in one. + +A filtered-out tool is never described to the model. It does not attempt the tool +and then narrate working around the failure, because it never knew the tool +existed. + +## When a tool fails + +Two kinds of failure, and the difference determines whether your turn survives. + +**A tool failed on its own terms.** The tool ran and did not succeed, or the +model called a tool that does not exist, or the arguments failed schema +validation. All three are things the model can recover from, so they arrive as a +`tool/result` event carrying `ToolResult(is_error=True)` with a message the model +can act on. Nothing is raised, and the turn continues. + +```python +async def open_ticket(args: Mapping[str, object]) -> ToolResult: + try: + ticket_id = await tracker.create(title=args["title"]) + except TrackerUnavailable: + return ToolResult(content="Issue tracker is down. Try again later.", + is_error=True) + return ToolResult(content=f"Opened {ticket_id}") +``` + +**A handler raised.** An unhandled exception is not representable as a tool +result, so it surfaces as `tool/failed`. + +The practical rule: catch what you expect and return `is_error=True` so the model +can adapt. Let genuinely unexpected exceptions propagate. + +## Trust + +A host tool handler runs in your process, with your imports and your credentials. +The agent does not sandbox it and does not attempt to. + +Tool arguments come from the model, which means they are untrusted input in the +same way user input is. Validate them in the handler. `input_schema` shapes what +the model is likely to send, but it is guidance to the model, not a security +boundary. + +To gate calls before they run, use [Approvals](approvals.md). + +## Errors + +- **`tool/failed`** a handler raised an unhandled exception. Not used for unknown + tool names or schema failures, which surface as `ToolResult(is_error=True)`. +- **`tool/denied`** an approval handler denied the call. Emitted as a recoverable + error alongside a `ToolResult(is_error=True)`, never raised. +- **`tool/approval_timeout`** the approval handler did not respond in time. Also + recoverable and never raised. + +See [Errors](errors.md) for the full registry. diff --git a/docs_v1/05-interface/turns.md b/docs_v1/05-interface/turns.md new file mode 100644 index 0000000..2627694 --- /dev/null +++ b/docs_v1/05-interface/turns.md @@ -0,0 +1,189 @@ +# Turns + +A turn is one task. You supply a prompt, the agent works until it produces a +reply, is cancelled, or fails. + +`run` and `stream` are two views onto the same execution. `run` consumes `stream` +to completion and returns the result carried by the final event, so there is one +turn execution path and no behavioral difference between them. + +## Interface + +```python +async def run( + self, + prompt: str, + *, + attachments: list[Attachment] | None = None, +) -> TurnResult: ... + +def stream( + self, + prompt: str, + *, + attachments: list[Attachment] | None = None, +) -> AsyncIterator[Event]: ... + +async def cancel(self) -> None: ... +``` + +All three are members of `Session`. + +## Awaiting a result + +```python +result = await session.run("Fix the failing test in tests/test_parser.py") +print(result.reply) +print(result.usage.cost_usd) +``` + +## Streaming events + +```python +async for event in session.stream("Refactor the parser."): + match event: + case MessageDelta(text=text): + print(text, end="", flush=True) + case ToolCall(name=name): + print(f"\n-> {name}") + case TurnCompleted(result=result): + print(f"\n[{result.stop_reason}]") +``` + +The full event registry and the ordering guarantees are in [Events](events.md). + +## Turn ids + +Every turn gets an id, generated by the agent. It appears on every event of the +turn and on `TurnResult.turn_id`, which is how you correlate a turn's events with +its result and with what the agent recorded. + +## Attachments + +Attachments accompany the prompt as additional input to the same turn. + +```python +result = await session.run( + "What is wrong with this screenshot?", + attachments=[Attachment(kind="image", name="failure.png", path="/tmp/failure.png")], +) +``` + +```python +@dataclass(frozen=True) +class Attachment: + kind: Literal["file", "image", "text"] + name: str + media_type: str | None = None + path: str | None = None + data: bytes | None = None + text: str | None = None +``` + +Exactly one of `path`, `data`, or `text` carries the content. `kind="text"` uses +`text`; `file` and `image` use `path` or `data`. Set `media_type` when you pass +`data` and the type is not inferable from `name`. + +## Cancelling + +`cancel` is safe to call from another task while a turn runs. + +```python +task = asyncio.create_task(session.run("...")) +await session.cancel() +``` + +A session runs one turn at a time, so there is never ambiguity about which turn +`cancel` applies to. + +Cancellation takes effect at the next await point inside the turn rather than +synchronously, so a turn in the middle of a tool call finishes that call first. +The turn ends with `stop_reason="cancelled"`, and the terminal `turn/completed` +event is emitted as it is for any other ending. + +## TurnResult + +```python +@dataclass(frozen=True) +class TurnResult: + turn_id: str + session_id: str + reply: str | None + stop_reason: Literal["completed", "cancelled", "error", "max_iterations"] + usage: Usage + error: AgentError | None +``` + +Stop reasons: + +- **`completed`** the agent produced a final reply and the turn ended. +- **`cancelled`** the turn ended through `cancel` or an approval resolved with + `cancel`. +- **`error`** the turn ended on a failure, carried on `error`. +- **`max_iterations`** the turn hit the iteration limit without a final reply. + +`error` is `None` when the stop reason is `completed`, populated when it is +`error`, and may be populated for `cancelled` or `max_iterations`. + +## Usage + +```python +@dataclass(frozen=True) +class Usage: + input_tokens: int + output_tokens: int + cache_read_tokens: int + cache_write_tokens: int + cost_usd: Decimal | None + model: str +``` + +The token fields overlap in one direction only, and getting this backwards is +the usual source of wrong cost math. + +``` +input_tokens charged input, includes cache_write_tokens +cache_write_tokens a subset of input_tokens +cache_read_tokens disjoint from input_tokens, billed at a different rate +output_tokens disjoint from all of the above +``` + +So the tokens the model actually read is `input_tokens + cache_read_tokens`, and +the tokens you were charged full input rate for is +`input_tokens - cache_write_tokens`. Adding all four double-counts cache writes. + +`cost_usd` is the agent's own accounting for the turn, computed from the token +counts and the model's rates. It is `None` when cost is unknown, which is not the +same as zero, so check for `None` before summing rather than defaulting to zero +and under-reporting. + +`model` is the model the turn ran on. A turn stays on one model, so this is +unambiguous per turn. Cumulative usage across a session is on +[`Session.usage`](sessions.md#usage). + +## How failures surface + +Where a failure appears depends on when it happened. + +- **Before the turn starts**, it is raised. From `run` directly, or from the + first `__anext__` of `stream`. +- **During the turn**, it is emitted as an `error` event and carried on the + `TurnResult` of the final `turn/completed` event. + +`run` re-raises a carried error only when `stop_reason` is `error` and `reply` is +`None`. A turn that failed partway but still produced a reply returns normally, +with the failure on `TurnResult.error` for you to inspect. + +That split matters when you are deciding where to put error handling. A `try` +around `run` catches configuration and startup failures. Inspecting +`result.error` catches everything that happened while the agent was working. + +## Errors + +- **`session/busy`** a turn is already running on this session. Retryable. +- **`turn/cancelled`** pairs with `stop_reason="cancelled"`. +- **`turn/max_iterations`** pairs with `stop_reason="max_iterations"`. +- **`turn/context_overflow`** the conversation exceeded the model's context + window. + +See [Errors](errors.md) for the full registry. diff --git a/docs_v1/06-providers/anthropic.md b/docs_v1/06-providers/anthropic.md new file mode 100644 index 0000000..e69de29 diff --git a/docs_v1/06-providers/azure-openai.md b/docs_v1/06-providers/azure-openai.md new file mode 100644 index 0000000..e69de29 diff --git a/docs_v1/06-providers/chat-completions.md b/docs_v1/06-providers/chat-completions.md new file mode 100644 index 0000000..e69de29 diff --git a/docs_v1/06-providers/gemini.md b/docs_v1/06-providers/gemini.md new file mode 100644 index 0000000..e69de29 diff --git a/docs_v1/06-providers/github-copilot.md b/docs_v1/06-providers/github-copilot.md new file mode 100644 index 0000000..e69de29 diff --git a/docs_v1/06-providers/index.md b/docs_v1/06-providers/index.md new file mode 100644 index 0000000..c9f77ad --- /dev/null +++ b/docs_v1/06-providers/index.md @@ -0,0 +1,89 @@ +# Providers + +Nine providers ship with the agent. Naming one in `ProviderConfig` and having a +credential it can resolve is the whole setup. + +```python +ProviderConfig(name="anthropic", model="claude-sonnet-5") +``` + +- [Anthropic](anthropic.md) +- [OpenAI](openai.md) +- [Azure OpenAI](azure-openai.md) +- [Gemini](gemini.md) +- [GitHub Copilot](github-copilot.md) +- [OpenAI ChatGPT](openai-chatgpt.md) +- [Ollama](ollama.md) +- [vLLM](vllm.md) +- [Chat Completions](chat-completions.md) + +Each page covers that provider's credentials, its environment variables, its +default model, and anything specific to it. + +The mechanism behind all of them, including the descriptor types, the resolution +order, and discovery, is in [Providers](../05-interface/providers.md). This +section is the per-provider detail. + +## Which one + +**Hosted, and you have a key.** Anthropic, OpenAI, Gemini. One environment +variable each and you are running. + +**Hosted, through your organization.** Azure OpenAI for an Azure deployment, +GitHub Copilot for a Copilot subscription, OpenAI ChatGPT for a ChatGPT account +rather than an API key. + +**Local or self-hosted.** Ollama for models on your own machine, vLLM for a +server you run. Neither sends anything outside your network, which is usually the +reason to pick them. + +**Anything else that speaks the OpenAI API.** Chat Completions points at an +arbitrary base URL. Use it for a gateway, a proxy, or a provider not listed here. + +## Credentials + +Environment first, then the stored credential file. This is the `gh` and `aws` +convention: a one-off export points a single run somewhere else without +disturbing what you have saved. + +``` +1. ProviderConfig.credentials +2. the provider's environment variable +3. the credential store +``` + +Each provider page names its own variables. To check what resolves right now: + +```bash +amplifier-agent doctor +``` + +That reports every provider, whether its credentials resolve, and where each one +came from. It is the fastest answer to why an agent says it cannot find a model. + +The same information is available in code, without constructing an agent: + +```python +from amplifier_agent import list_providers + +for status in await list_providers(): + print(status.descriptor.name, status.available, status.credential_source) +``` + +## Models + +The agent holds no table of model names. A provider reports its own models at +runtime, so the list cannot drift from what the provider actually offers. + +```bash +amplifier-agent models list --provider anthropic +``` + +```python +from amplifier_agent import list_models + +for model in await list_models("anthropic"): + print(model.id, model.context_window) +``` + +Omit `model` in `ProviderConfig` to take the provider's default. diff --git a/docs_v1/06-providers/ollama.md b/docs_v1/06-providers/ollama.md new file mode 100644 index 0000000..e69de29 diff --git a/docs_v1/06-providers/openai-chatgpt.md b/docs_v1/06-providers/openai-chatgpt.md new file mode 100644 index 0000000..e69de29 diff --git a/docs_v1/06-providers/openai.md b/docs_v1/06-providers/openai.md new file mode 100644 index 0000000..e69de29 diff --git a/docs_v1/06-providers/vllm.md b/docs_v1/06-providers/vllm.md new file mode 100644 index 0000000..e69de29 diff --git a/docs_v1/07-surfaces/cli.md b/docs_v1/07-surfaces/cli.md new file mode 100644 index 0000000..5edfcfc --- /dev/null +++ b/docs_v1/07-surfaces/cli.md @@ -0,0 +1,197 @@ +# CLI + +The `amplifier-agent` command. Every agent command is a composition of library +calls with a terminal-shaped presentation on top, so anything here has a Python +equivalent and the reverse holds too. + +```bash +amplifier-agent run "Find the failing test in tests/ and fix it." +``` + +## Commands + +Agent commands compose the library. Each one is listed with the calls it makes, +which is the whole specification of what it does. + +``` +run create_agent, create_session | resume_session, run | stream, close +sessions create_agent, list_sessions, close +sessions rm create_agent, delete_session, close +skills create_agent, list_skills, close +models list_models(provider) +providers list_providers() +``` + +`providers` and `models` call module-level functions and never construct an +agent, because you run them to decide what to put in a config. + +Installation commands manage the install rather than the agent. They have no +library equivalent because they are not agent capability. + +``` +version what is installed, and the surface version it speaks +doctor check credentials, dependencies, and writable paths +auth store and remove credentials in the credential store +prepare warm the caches so the first run is not the slow one +cache clear drop those caches +update upgrade the installed distribution +migrate move state written by an older layout +config show print the resolved configuration and where each value came from +serve run the HTTP surface +``` + +## Running a turn + +```bash +amplifier-agent run "Summarize src/parser.py" +amplifier-agent run --session-id ticket-4417 --resume "Now write the tests" +amplifier-agent run --prompt-file ./prompt.md --workspace api +``` + +- **`--session-id`** names the session. Omit it and the turn is ephemeral. +- **`--resume`** resumes that session instead of starting a new one. +- **`--workspace`** and **`--cwd`** set `AgentConfig.workspace` and + `AgentConfig.cwd`. +- **`--prompt-file`** reads the prompt from a file, for prompts too long or too + quote-heavy for a shell. + +## The two streams + +stdout carries the result. stderr carries everything else. They are independent +because they answer to different readers: stdout is parsed, stderr is watched. + +``` +--output text | json what lands on stdout at the end of the turn +--display text | ndjson what lands on stderr while the turn runs +``` + +This split is what makes the CLI safe to pipe. A tool printing progress to +stdout would corrupt the one thing a caller is trying to parse, so nothing +except the envelope is ever written there. + +```bash +amplifier-agent run "..." --output json --display ndjson 2>events.log | jq .reply +``` + +## The result envelope + +`--output json` writes one JSON object, once, when the turn ends. It is a +serialization of `TurnResult` plus what the surface itself knows. + +```json +{ + "surface_version": "1", + "session_id": "ticket-4417", + "turn_id": "01J8XZ...", + "reply": "Fixed the off-by-one in tokenize().", + "stop_reason": "completed", + "usage": { + "input_tokens": 18432, + "output_tokens": 512, + "cache_read_tokens": 16000, + "cache_write_tokens": 2048, + "cost_usd": "0.0431", + "model": "claude-sonnet-5" + }, + "error": null, + "duration_ms": 8140, + "agent_version": "0.17.0" +} +``` + +`cost_usd` is a decimal string rather than a JSON number, because JSON numbers +are floats and money is not. Parse it as a decimal. + +`error` is `null` on success and otherwise carries the `AgentError`: + +```json +{"code": "provider/rate_limited", "message": "...", "retryable": true, "details": {}} +``` + +A turn that spent tokens and then failed still reports its usage. Those tokens +were charged whether or not a reply came back, and omitting them would make +spend invisible on exactly the runs worth investigating. + +`--output text` prints `reply` and nothing else, so the common case pipes +cleanly into another command. + +## The event stream + +`--display ndjson` writes one JSON object per line to stderr as the turn runs, +one per event in the [registry](../05-interface/events.md). Each line is the +event serialized directly, with its `type` field intact. + +```json +{"type": "turn/started", "session_id": "...", "turn_id": "...", "prompt": "..."} +{"type": "tool/call", "session_id": "...", "turn_id": "...", "tool_call_id": "c1", "name": "read_file", "arguments": {"path": "src/parser.py"}, "source": "builtin"} +{"type": "tool/result", "session_id": "...", "turn_id": "...", "tool_call_id": "c1", "name": "read_file", "duration_ms": 12, "result": {"content": "...", "is_error": false}} +``` + +Field names match the library's, so one reader handles these frames, the +recorded [event log](../04-context-intelligence.md), and the HTTP surface. + +`--display text` renders the same events for a person to read. `--quiet` +suppresses them. Neither changes which events occur. + +## Approvals + +``` +(default) prompt on the terminal, deny when there is no terminal +--yes allow every request +--no deny every request +``` + +Each of these is an `ApprovalHandler` the CLI supplies, so the behavior is the +library's and only the prompt is the CLI's. + +The no-terminal case denies rather than allows. A run in CI does not gain +permission because nobody is watching, which is the same rule the library states +for a missing handler. Pass `--yes` when you mean it, and the decision is +recorded in your pipeline rather than implied by its environment. + +## Configuration + +``` +--config a config file, or $AMPLIFIER_AGENT_CONFIG +--provider overrides provider.name +--model overrides provider.model +--mcp-config MCP servers +``` + +Flags override file values, which override defaults. `config show` prints the +resolved result with the origin of each value, which is faster than reasoning +about the precedence. + +## Exit codes + +``` +0 the turn completed +1 the turn ran and failed; error is populated in the envelope +2 the command was used wrong, or the configuration is invalid +130 interrupted +``` + +Exit code 2 means the turn never started, so nothing was spent. Exit code 1 +means it did, and the envelope tells you what it cost before it failed. + +An envelope is written on every one of these, including the failures. A caller +parsing stdout does not have to special-case a missing document. + +## Versioning + +`version` reports both the release and the surface version, and +`--surface-version ` asserts the one the caller expects. + +```bash +amplifier-agent version +amplifier-agent run "..." --surface-version 1 --output json +``` + +The surface version covers the argv shape, the envelope, and the event frames. +It is not the library's `contract_version` and not the release version, because +the CLI can gain a flag without the library changing and the reverse is just as +true. + +A mismatch fails before the turn starts, with exit code 2. A wrapper and the +command it spawns disagreeing about the shape of the envelope is not something +to discover halfway through parsing one. diff --git a/docs_v1/07-surfaces/http.md b/docs_v1/07-surfaces/http.md new file mode 100644 index 0000000..dbf7f73 --- /dev/null +++ b/docs_v1/07-surfaces/http.md @@ -0,0 +1,219 @@ +# HTTP + +An OpenAI-compatible server, for callers that are not Python or not on the same +machine. Start it with `amplifier-agent serve`. + +It speaks the chat completions API, so a client already targeting that shape +works against it without changes. Everything specific to this agent is carried +in an `amplifier` extension object, which conforming clients ignore. + +```bash +amplifier-agent serve --host 127.0.0.1 --port 8080 +``` + +## Endpoints + +``` +POST /v1/chat/completions run a turn +GET /v1/models list_models across configured providers +GET /v1/skills list_skills +GET /health liveness, unauthenticated +``` + +The server holds one `Agent` for its lifetime and creates a `Session` per +request. `create_agent` runs at startup, so a bad configuration fails the +process rather than the first request. + +## A request + +```json +POST /v1/chat/completions +Authorization: Bearer + +{ + "model": "claude-sonnet-5", + "messages": [ + {"role": "system", "content": "Prefer small commits."}, + {"role": "user", "content": "Summarize src/parser.py"} + ], + "stream": true, + "amplifier": {"session_id": "ticket-4417", "workspace": "api"} +} +``` + +`model` selects among the providers the server was configured with. A model no +configured provider offers is `config/unknown_model`. + +## How messages become a turn + +The chat completions API is stateless and a `Session` is not, so the mapping is +explicit rather than inferred. + +**With `amplifier.session_id`**, the server resumes that session and uses only +the final message as the prompt. History lives on the server, so clients do not +resend it and a long conversation does not grow the request. + +**Without it**, the server creates an ephemeral session, replays `messages` as +its history, and runs the final message. This is the stateless mode ordinary +OpenAI clients get for free. + +Either way one rule decides what the prompt is: + +``` +Only a final role="user" message becomes the prompt. +``` + +Everything before it is history. A request whose last message is not from the +user has an empty prompt and continues from history rather than searching +backwards for the most recent user text. + +That rule is a privilege boundary, not a parsing convenience. The +[skill sigil](../05-interface/skills.md) is honored only on a genuine user turn, +so a request that ended in a tool result or an assistant message cannot dispatch +a skill the user never submitted. + +Client `system` messages are wrapped as user-supplied instructions and injected +at the start of history rather than becoming the agent's own instructions. A +client cannot replace the agent's instruction set by sending a system message; +`AgentConfig.instructions` is the server operator's to set. + +## Streaming + +`stream: true` returns Server-Sent Events in the OpenAI chunk format. + +``` +data: {"choices":[{"delta":{"content":"Looking at "},"index":0}],...} +data: {"choices":[{"delta":{},"finish_reason":"stop","index":0}],...} +data: [DONE] +``` + +The mapping from the [event registry](../05-interface/events.md): + +``` +message/delta -> delta.content +tool/call -> delta.tool_calls (host tools only; see below) +turn/completed -> finish_reason, then [DONE] +usage -> the usage object on the final chunk +``` + +Events with no OpenAI equivalent are carried on an `amplifier` key on the chunk, +which is where `thinking/delta`, `tool/call` for builtin tools, `tool/result`, +and `error` arrive. A client that ignores the key gets a correct, ordinary +OpenAI stream. A client that reads it gets everything the library would have +yielded. + +```json +{"choices":[{"delta":{},"index":0}], + "amplifier":{"type":"tool/call","tool_call_id":"c1","name":"read_file", + "arguments":{"path":"src/parser.py"},"source":"builtin"}} +``` + +`stream: false` returns one completion, with the same `amplifier` key carrying +the events the turn produced. + +`finish_reason` maps from `TurnResult.stop_reason`: + +``` +completed -> "stop" +max_iterations -> "length" +cancelled -> "stop", with the error on amplifier.error +error -> "stop", with the error on amplifier.error +``` + +## Tools + +Builtin and MCP tools run on the server. The client sees them as +`amplifier`-carried events and does not execute anything. + +Host tools are how a client contributes its own, and over HTTP they use the +OpenAI round trip rather than a callback, because the handler lives in the +client's process and cannot be called from the server's. + +``` +1. client sends tools[] with name, description, parameters +2. server returns delta.tool_calls and finishes with finish_reason "tool_calls" +3. client runs the handler +4. client posts back with role "tool" appended to messages +``` + +This is the standard OpenAI contract and it is also the honest one. A host tool +is code in the caller's process, so the caller has to be the one that runs it. +The library's in-process `HostTool.handler` and this round trip are the same +capability expressed for a caller who is and is not in the same process. + +`amplifier.tools.allow` and `amplifier.tools.deny` filter the combined set by +name, exactly as `ToolsConfig` does. + +## Approvals + +Client-supplied tools need no approval mechanism, because step 3 above is the +client deciding whether to run its own code. + +Builtin and MCP tools are gated by a policy the operator configures on the +server, since there is no request-scoped callback to consult. + +``` +deny the default: every request that would need approval is denied +allow approve everything, for a trusted single-tenant deployment +patterns approve calls matching a configured allowlist, deny the rest +``` + +The default is `deny` for the same reason it is in the library. A server does +not become more permitted than an interactive session because it has no one to +ask. Choosing `allow` is a deployment decision that lives in the server's +configuration, where it can be reviewed, rather than in the absence of one. + +Approval activity is reported as `approval/requested` and `approval/resolved` on +the `amplifier` key so a client can show what happened, and it cannot answer. + +## Providers + +The server is configured with a registry rather than a single provider, because +`model` selects per request. + +```json +{ + "providers": { + "anthropic": {"credentials": {"api_key": "..."}}, + "openai": {"credentials": {"api_key": "..."}} + } +} +``` + +`GET /v1/models` returns the union across them in OpenAI's model-list shape. +Credentials resolve at startup, and a provider whose required fields do not +resolve is dropped loudly rather than failing the first request that needs it. + +## Auth + +Every endpoint except `/health` requires `Authorization: Bearer `, +checked against the tokens the server was started with. A server started with no +token refuses to bind unless it is bound to loopback, so the default posture is +either authenticated or unreachable. + +The server holds provider credentials, so an unauthenticated one is a credential +proxy for whoever can reach it. + +## Errors + +Failures use OpenAI's error envelope, with the agent's code carried intact. + +```json +{"error": {"message": "the provider rate-limited the request", + "type": "provider/rate_limited", + "code": "provider/rate_limited", + "param": null, + "amplifier": {"retryable": true, "details": {}}}} +``` + +``` +config/* 400 +session/* 404 for not_found, 409 for busy +provider/* 502, or 429 for rate_limited +tool/* carried in the stream, not an HTTP status +internal 500 +``` + +A failure during a turn that already started streaming arrives as an +`amplifier.error` event and a terminal chunk, because the status line is long +gone by then. diff --git a/docs_v1/07-surfaces/index.md b/docs_v1/07-surfaces/index.md new file mode 100644 index 0000000..a9503c7 --- /dev/null +++ b/docs_v1/07-surfaces/index.md @@ -0,0 +1,72 @@ +# Surfaces + +Three other ways to reach the agent. Each is built on the library and changes how +you talk to it, not what it can do. + +- [CLI](cli.md) the `amplifier-agent` command, for shells and scripts. +- [HTTP](http.md) an OpenAI-compatible server, for anything over a network. +- [TypeScript](typescript.md) an SDK for Node, wrapping the CLI. + +## They add nothing + +A surface adds transport and presentation. It does not add capability, and it +does not take one away either. + +That second half is the one worth stating. A surface that quietly resolves +approvals on your behalf, or drops an event type it does not know how to render, +is not a thinner path to the same product. It is a different product with weaker +guarantees, and that is a defect rather than a convenience. + +So anything you can do here, you can do from the library, and the reverse holds +too. Every CLI command is a composition of library calls with a terminal-shaped +presentation on top, and each one is documented next to the calls it makes. A +command with no library equivalent would mean the CLI had grown a capability, and +that is the thing this rule exists to prevent. + +## Choosing one + +**The library**, when you are writing Python and want the agent inside your +application. Everything else here is built on it, so it is the only one with no +translation layer between you and the agent. + +**The CLI**, for shell pipelines, CI steps, and anything where a process +boundary is what you want. It is also the fastest way to try something without +writing code. + +**HTTP**, when the agent runs somewhere other than the caller, or when the caller +is not Python. It speaks the OpenAI chat completions API, so clients that already +target that shape work against it. + +**TypeScript**, when the caller is Node. It spawns the CLI and parses its output, +so it needs both installed. + +## Versions and compatibility + +The CLI and a wrapper SDK agree on a surface version, and they compare it +exactly. Mismatched versions refuse each other rather than negotiating down, +because a partial agreement between two halves of one product is worse than a +clean failure. + +A surface version covers one surface's own serialization: for the CLI, the argv +shape, the result envelope, and the event frames. It moves when that shape +moves, which is not when the library's `contract_version` moves and not when the +release version does. + +For the same reason there is no capability handshake. A wrapper is a pipe, and a +pipe does not need to be told what will flow through it. What it does need is to +not choke on something it has not seen: + +``` +Wrappers ignore fields they do not recognize and forward event types they +do not recognize, unchanged. +``` + +That rule is what lets the agent gain an event without every wrapper needing a +release first. A wrapper that rejects an unknown event type breaks on the first +additive change, and additive changes are the common case. + +## Installing them + +The CLI ships in the same distribution as the library. The TypeScript SDK is a +separate package and does not bundle the agent, so it needs both. See +[Install](../01-install.md). diff --git a/docs_v1/07-surfaces/typescript.md b/docs_v1/07-surfaces/typescript.md new file mode 100644 index 0000000..81d44f7 --- /dev/null +++ b/docs_v1/07-surfaces/typescript.md @@ -0,0 +1,163 @@ +# TypeScript + +An SDK for Node. It spawns the CLI and speaks its contract, so it needs both the +npm package and the agent installed. + +```bash +npm install @microsoft/amplifier-agent +``` + +```typescript +import { createAgent } from "@microsoft/amplifier-agent"; + +const agent = await createAgent({ + provider: { name: "anthropic", model: "claude-sonnet-5" }, +}); + +const session = await agent.createSession(); +const result = await session.run("Summarize src/parser.py"); +console.log(result.reply); +``` + +## Shape + +The API mirrors the library, in TypeScript naming. `createAgent`, +`createSession`, `resumeSession`, `listSessions`, `deleteSession`, `listSkills`, +`run`, `stream`, `cancel`, `fork`, `close`. The types are the +[interface](../05-interface/index.md) types with camelCase fields. + +`stream` is an async iterable, so the Python and TypeScript reading loops have +the same shape: + +```typescript +for await (const event of session.stream("Refactor the parser.")) { + if (event.type === "message/delta") process.stdout.write(event.text); + if (event.type === "tool/call") console.error(`\n[${event.name}]`); +} +``` + +## How it works + +Each turn is one CLI invocation. The wrapper assembles argv, spawns the process, +and reads both of its streams. + +``` +argv amplifier-agent run --output json --display ndjson --surface-version 1 ... +stdout one result envelope, at the end +stderr one event frame per line, as the turn runs +stdin approval decisions, one per line +``` + +The envelope becomes `TurnResult`. The stderr frames become the events `stream` +yields. `run` is `stream` consumed to completion, the same as in the library. + +Values too large or too awkward for argv are spilled to temporary files and +passed by path: + +``` +prompt --prompt-file long prompts, and prompts with shell metacharacters +mcp servers --mcp-config the McpServerConfig list, serialized +config --config everything else in AgentConfig +``` + +Spill files are created with owner-only permissions and removed when the process +exits. They hold prompts and MCP server environments, which routinely contain +credentials. + +A session is server state, so `createSession` allocates an id and every +subsequent `run` passes `--session-id` and `--resume`. The conversation lives on +disk between invocations rather than in the wrapper. + +## Approvals + +Approvals are a callback in the library, and a callback needs a channel back +into a running turn. The wrapper has one: requests arrive as events on stderr, +decisions go back on stdin. + +```typescript +const agent = await createAgent({ + provider: { name: "anthropic" }, + approvals: async (request) => { + if (AUTO_ALLOW.has(request.toolName)) return { action: "allow" }; + return { action: "deny", reason: "not in the allowlist" }; + }, +}); +``` + +``` +stderr {"type":"approval/requested","approval_id":"a1","tool_name":"write_file",...} +stdin {"approval_id":"a1","action":"deny","reason":"not in the allowlist"} +``` + +Decisions are correlated by `approval_id`, so a handler that takes its time does +not block the frames still arriving. A handler that does not answer within +`timeout_ms` produces `tool/approval_timeout`, which the agent treats as a +failed call rather than an approval. + +Omitting `approvals` denies every request that needs one, matching the library. +The wrapper does not substitute a permissive default when no handler is +supplied, because a surface that quietly resolves approvals on your behalf is a +weaker product wearing the same name. + +## Tools + +Built-in and MCP tools work as they do everywhere. `mcpServers` on the config is +serialized to a spill file and passed through. + +Host tools are a Python callable, and there is no Python process here to hold +one. Contribute tools from Node through an MCP server, which is the same flat +set to the model and the same `tool/call` events with `source: "mcp"` to you. + +`tools.allow` and `tools.deny` filter by name across every source, unchanged. + +## Versioning + +The wrapper asserts the CLI surface version it was built against, and the two +compare it exactly. + +``` +--surface-version 1 +``` + +Mismatched versions refuse each other before the turn starts rather than +negotiating down, because a partial agreement between two halves of one product +is worse than a clean failure. The package exports `version`, `surfaceVersion`, +and `contractVersion` so a caller can report all three without spawning +anything. + +There is no capability handshake. A wrapper is a pipe, and the rule that keeps a +pipe working is: + +``` +Ignore fields you do not recognize. Forward event types you do not +recognize, unchanged. +``` + +An unknown event type surfaces as `{ type: string, ... }` rather than being +dropped or throwing. That is what lets the agent gain an event without every +wrapper needing a release first, and additive changes are the common case. + +## Errors + +`AgentError` is a TypeScript `Error` subclass carrying the same closed set of +[codes](../05-interface/errors.md), with `retryable` and `details`. + +```typescript +try { + await session.run(prompt); +} catch (err) { + if (err instanceof AgentError && err.retryable) await backoff(); + else throw err; +} +``` + +The raise-or-emit rule is the library's. A failure before the turn starts +rejects the promise. A failure during it arrives as an `error` event and on +`TurnResult.error`, and `run` re-raises only when `stopReason` is `"error"` and +there is no reply. + +Two failures belong to this surface rather than the agent, and both are +`internal` with the cause in `details`: the CLI could not be spawned, and its +output could not be parsed. A non-JSON line on stdout is a corrupted envelope +rather than something to guess at, so the wrapper reports it instead of +recovering. diff --git a/docs_v1/08-development/index.md b/docs_v1/08-development/index.md new file mode 100644 index 0000000..35164da --- /dev/null +++ b/docs_v1/08-development/index.md @@ -0,0 +1,130 @@ +# Development + +Working on Amplifier Agent itself. + +Testing here splits along one line: whether a change works at all, and whether +the agent is any good. Those are different questions with different tools, and +conflating them produces a suite that is slow, flaky, and answers neither. + +``` +End-to-end tests Does the shipped binary work from a realistic install? + Fast, cheap, binary pass or fail. + +Evaluations Is the agent good at the task? + Slow, expensive, scored rather than passed. +``` + +## End-to-end tests + +E2E runs the real CLI and HTTP server inside an isolated container, installed +from a git mirror of your working tree. It proves the shipped binary works from a +realistic install rather than proving that unit tests pass. + +The tests are deliberately light. The baseline is that the command ran without +erroring, exit 0 or HTTP 200, plus a small structural assertion on the output. +Output quality is out of scope and belongs to evaluations. + +```bash +uv run python tests/e2e/framework/cli.py run # push code, fresh container, all suites +uv run python tests/e2e/framework/cli.py run skills # one suite +uv run python tests/e2e/framework/cli.py run --skip-setup # re-run against the existing container +uv run python tests/e2e/framework/cli.py up # provision without running +uv run python tests/e2e/framework/cli.py down # tear the container down +``` + +The framework is split so that adding tests never means touching machinery: + +``` +tests/e2e/ + framework/ the machinery, stable and rarely edited + suites/ the tests, one package per feature + / + cases.py the case data + test_.py a thin pytest wrapper + fixtures/ optional files pushed into the container +``` + +To cover a new feature, add `suites//`. Nothing in `framework/` changes. + +### Why a container + +Because "works on my machine" and "works when installed" are different claims, +and only the second one matters to a user. The container installs from a mirror +of your working tree, so the thing under test is the thing that ships, including +the install path. + +It also means real credentials. `ANTHROPIC_API_KEY` is passed through and is +required by any suite that runs a model. Suites needing a credential nobody is +guaranteed to have skip themselves rather than failing, so a full run stays green +for someone without a Google key or a vLLM server. + +Note that credentials are snapshotted into the container at launch. Exporting one +after the container is already running has no effect, and the failure surfaces +much later as an opaque provider auth error. + +## Evaluations + +Evaluations run a set of tasks against a set of agents and score the results. +Each trial provisions an isolated environment, drives the agent, extracts its +work, and grades it. + +```bash +uv run python run.py validate # check every agent and task definition + +uv run python run.py run \ + --agents amplifier-agent,opencode-vanilla \ + --tasks websearch-pdf \ + --trials 1 \ + --max-parallel 3 +``` + +`validate` exits non-zero on any invalid definition, which makes it the cheap +check to run before a long matrix. + +The structure is one canonical definition per agent and per task: + +``` +agents// meta.yaml, install.yaml, invocation.md, extract.yaml +tasks/// task.yaml, grader.yaml, profile.yaml, workspace/ +providers/ provider configs the agents reference +``` + +Task groups are either vendored or fetched at run time: + +``` +benchmark/ ours, vendored in full +custom/ ours, vendored in full +swe-bench-pro/ stores an instance id, fetches the problem at run time +automation-bench/ stores a task name, fetches the task at run time +``` + +The fetched groups store only a selector, so no third-party benchmark content is +redistributed here. + +Two external benchmarks live alongside in their own directories, `deep-swe/` and +`jobbench/`, because each owns a task format, prompt contract, and grading path +that has to be reproduced exactly for its scores to mean anything. Each has its +own README. + +### Reading a score honestly + +A score is comparable to another score produced the same way, and to nothing +else. Judge model, prompt, and rubric all move the number. + +`jobbench` is the clearest case: its authors validated their rubrics against a +different judge model than the one configured here. Scores from this harness are +internally consistent and valid for comparing one agent arm against another. They +are not comparable to the published leaderboard, and reporting them as if they +were would be wrong. + +## Which one to reach for + +**A change to behavior, a flag, an output shape, or the install path** is an E2E +question. Add a case to the relevant suite, or a new suite. + +**A change intended to make the agent better at something** is an evaluation +question. E2E will happily pass a change that made the agent worse, because exit +0 is all it checks. + +Most changes want the first. Reach for the second when the claim you are making +is about quality, and be ready for it to take a while.