From 31dab8324ccc227a343b9cc6d6179ed92f188704 Mon Sep 17 00:00:00 2001 From: DavidKoleczek <45405824+DavidKoleczek@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:27:38 -0400 Subject: [PATCH] docs: add a library-first documentation set for the v1 interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds docs_v1/, documenting amplifier-agent as a library that hosts embed, with the CLI, HTTP face, and TypeScript SDK as surfaces built on it rather than as peers. The organizing rule is that the library is the product. Every surface adds transport and presentation and no capability, in both directions: a surface that quietly resolves approvals or drops an event it cannot render is a weaker product, not a thinner path to the same one. CLI commands are therefore documented next to the library calls that produce them, so a command with no library equivalent is visible as a defect. Structure: 00-index what it is, and how it differs from Amplifier App CLI 01-install library first, then the CLI and SDK 02-quickstart config in, agent out, one reply 03-configuration every AgentConfig field 04-context-intelligence session recording and forwarding 05-interface the complete public surface, one page per area 06-providers per-provider credentials and models 07-surfaces CLI, HTTP, TypeScript 08-development end-to-end tests and evaluations Notable interface decisions captured here: - No capability negotiation. It presupposes divergence the wrapper rule forbids, and under exact protocol-version equality the two sides cannot differ. Forward-compatible parsing replaces it: wrappers ignore unknown fields and forward unknown event types unchanged. - Turn ids are outbound only. The agent assigns them; cancel() takes no argument because a session runs one turn at a time. - Version information is module-level, so it is readable without a constructed agent. The reason to check a version is usually that create_agent is failing. - Session storage keeps the context-intelligence/ layout, so existing tooling reads a session directory without adaptation. The two record files carry distinct format identities so each reader refuses the file that is not its own. docs/ is unchanged. Per-provider detail pages are scaffolded and empty. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- docs_v1/00-index.md | 121 ++++++++++ docs_v1/01-install.md | 127 +++++++++++ docs_v1/02-quickstart.md | 188 ++++++++++++++++ docs_v1/03-configuration.md | 250 +++++++++++++++++++++ docs_v1/04-context-intelligence.md | 267 +++++++++++++++++++++++ docs_v1/05-interface/agent.md | 127 +++++++++++ docs_v1/05-interface/approvals.md | 133 +++++++++++ docs_v1/05-interface/errors.md | 135 ++++++++++++ docs_v1/05-interface/events.md | 175 +++++++++++++++ docs_v1/05-interface/index.md | 154 +++++++++++++ docs_v1/05-interface/providers.md | 178 +++++++++++++++ docs_v1/05-interface/sessions.md | 207 ++++++++++++++++++ docs_v1/05-interface/skills.md | 152 +++++++++++++ docs_v1/05-interface/storage.md | 110 ++++++++++ docs_v1/05-interface/tools.md | 139 ++++++++++++ docs_v1/05-interface/turns.md | 189 ++++++++++++++++ docs_v1/06-providers/anthropic.md | 0 docs_v1/06-providers/azure-openai.md | 0 docs_v1/06-providers/chat-completions.md | 0 docs_v1/06-providers/gemini.md | 0 docs_v1/06-providers/github-copilot.md | 0 docs_v1/06-providers/index.md | 89 ++++++++ docs_v1/06-providers/ollama.md | 0 docs_v1/06-providers/openai-chatgpt.md | 0 docs_v1/06-providers/openai.md | 0 docs_v1/06-providers/vllm.md | 0 docs_v1/07-surfaces/cli.md | 197 +++++++++++++++++ docs_v1/07-surfaces/http.md | 219 +++++++++++++++++++ docs_v1/07-surfaces/index.md | 72 ++++++ docs_v1/07-surfaces/typescript.md | 163 ++++++++++++++ docs_v1/08-development/index.md | 130 +++++++++++ 31 files changed, 3522 insertions(+) create mode 100644 docs_v1/00-index.md create mode 100644 docs_v1/01-install.md create mode 100644 docs_v1/02-quickstart.md create mode 100644 docs_v1/03-configuration.md create mode 100644 docs_v1/04-context-intelligence.md create mode 100644 docs_v1/05-interface/agent.md create mode 100644 docs_v1/05-interface/approvals.md create mode 100644 docs_v1/05-interface/errors.md create mode 100644 docs_v1/05-interface/events.md create mode 100644 docs_v1/05-interface/index.md create mode 100644 docs_v1/05-interface/providers.md create mode 100644 docs_v1/05-interface/sessions.md create mode 100644 docs_v1/05-interface/skills.md create mode 100644 docs_v1/05-interface/storage.md create mode 100644 docs_v1/05-interface/tools.md create mode 100644 docs_v1/05-interface/turns.md create mode 100644 docs_v1/06-providers/anthropic.md create mode 100644 docs_v1/06-providers/azure-openai.md create mode 100644 docs_v1/06-providers/chat-completions.md create mode 100644 docs_v1/06-providers/gemini.md create mode 100644 docs_v1/06-providers/github-copilot.md create mode 100644 docs_v1/06-providers/index.md create mode 100644 docs_v1/06-providers/ollama.md create mode 100644 docs_v1/06-providers/openai-chatgpt.md create mode 100644 docs_v1/06-providers/openai.md create mode 100644 docs_v1/06-providers/vllm.md create mode 100644 docs_v1/07-surfaces/cli.md create mode 100644 docs_v1/07-surfaces/http.md create mode 100644 docs_v1/07-surfaces/index.md create mode 100644 docs_v1/07-surfaces/typescript.md create mode 100644 docs_v1/08-development/index.md diff --git a/docs_v1/00-index.md b/docs_v1/00-index.md new file mode 100644 index 00000000..0d3ed4cb --- /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 00000000..843f71b1 --- /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 00000000..f57efdd9 --- /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 00000000..b680cf00 --- /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 00000000..32cba162 --- /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 00000000..d23830ed --- /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 00000000..78501f28 --- /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 00000000..e35739a8 --- /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 00000000..80d44ec2 --- /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 00000000..a4e93822 --- /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 00000000..d680837f --- /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 00000000..ff99192b --- /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 00000000..c7048444 --- /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 00000000..a58cf858 --- /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 00000000..1aeb4db9 --- /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 00000000..26276949 --- /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 00000000..e69de29b diff --git a/docs_v1/06-providers/azure-openai.md b/docs_v1/06-providers/azure-openai.md new file mode 100644 index 00000000..e69de29b diff --git a/docs_v1/06-providers/chat-completions.md b/docs_v1/06-providers/chat-completions.md new file mode 100644 index 00000000..e69de29b diff --git a/docs_v1/06-providers/gemini.md b/docs_v1/06-providers/gemini.md new file mode 100644 index 00000000..e69de29b diff --git a/docs_v1/06-providers/github-copilot.md b/docs_v1/06-providers/github-copilot.md new file mode 100644 index 00000000..e69de29b diff --git a/docs_v1/06-providers/index.md b/docs_v1/06-providers/index.md new file mode 100644 index 00000000..c9f77add --- /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 00000000..e69de29b diff --git a/docs_v1/06-providers/openai-chatgpt.md b/docs_v1/06-providers/openai-chatgpt.md new file mode 100644 index 00000000..e69de29b diff --git a/docs_v1/06-providers/openai.md b/docs_v1/06-providers/openai.md new file mode 100644 index 00000000..e69de29b diff --git a/docs_v1/06-providers/vllm.md b/docs_v1/06-providers/vllm.md new file mode 100644 index 00000000..e69de29b diff --git a/docs_v1/07-surfaces/cli.md b/docs_v1/07-surfaces/cli.md new file mode 100644 index 00000000..5edfcfce --- /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 00000000..dbf7f731 --- /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 00000000..a9503c76 --- /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 00000000..81d44f7c --- /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 00000000..35164da7 --- /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.