From 9c416b41bc3b6ad61d94a6bf0ee3febd3fce5b5a Mon Sep 17 00:00:00 2001 From: DavidKoleczek <45405824+DavidKoleczek@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:45:36 -0400 Subject: [PATCH] docs: lead the integration story with the engine library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs presented five integration surfaces as peers, which understated the one that is actually the contract. `amplifier_agent_lib` is the engine; the CLI, the HTTP face, and both wrapper SDKs are adapters over it. Weighting them equally led Python hosts to spawn a subprocess when they could have imported the library, and left the in-process path documented in five lines with no code. INTEGRATION.md and skills/amplifier-agent/SKILL.md now open with a complete, runnable embedding and reframe the wrappers as what you reach for when your host cannot import Python in-process. Install becomes one shared section, since a single distribution ships the library and the binary together, and the binary is presented as the setup and diagnostics surface (doctor, auth set, models list) rather than the runtime path. Checklists and error codes are split by surface so an embedder is not reading binary_not_found and exit codes as their own concern. spec/engine-api.md was not followable end to end: it documented `Engine` but never named `make_turn_handler`, so a reader following it exactly could not construct one. It now carries the turn assembly sequence and names the two symbols on that path that sit outside the lib's public surface. Three claims in it were also stale and are corrected against observed behavior: - `TurnSubmitResult` carries eight keys, not three. The five usage fields landed with per-turn usage accounting. - `bundle_override` does not decide which bundle serves the turn; the `make_turn_handler` closure does. Passing it avoids a redundant re-prepare inside `boot()`, which is why the CLI passes it in production. - Child-to-parent cost bridging is implemented and no longer a limitation. The "no second session-factory path" non-goal is narrowed to embedders, since amplifier_agent_http reaches the runtime through a private path of its own. Also documents behavior that embedders hit and had nowhere to read about: continuity is a function of session id and workspace rather than object lifetime (a second submit_turn on one booted Engine succeeds but does not see the first turn), clearing mount_plan["providers"] before injection is load-bearing, CliApprovalSystem() with no arguments declines, and importing the library overwrites AMPLIFIER_HOME in os.environ. README, ARCHITECTURE, LAYERS_AND_RELEASES, SPEC, and ECOSYSTEM are updated only where they contradicted the above. Both published code samples were executed verbatim against a fresh install from git and returned a real agent reply. Scope of impact: docs-only. No engine, wrapper, or protocol change. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- README.md | 26 ++-- docs/ARCHITECTURE.md | 20 ++- docs/ECOSYSTEM.md | 3 +- docs/INTEGRATION.md | 226 ++++++++++++++++++++++++++++---- docs/LAYERS_AND_RELEASES.md | 42 +++--- docs/SPEC.md | 5 +- docs/spec/engine-api.md | 129 +++++++++++++++--- skills/amplifier-agent/SKILL.md | 184 ++++++++++++++++++++------ 8 files changed, 516 insertions(+), 119 deletions(-) diff --git a/README.md b/README.md index ca989f0e..e80f3044 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,8 @@ Alternatively, copy [`skills/amplifier-agent/SKILL.md`](skills/amplifier-agent/S **`amplifier-agent`** is an agent engine that other software runs on. Give it a prompt and it runs the full loop, with tools, sub-agents, skills, and MCP, and returns a result. -Anything that can spawn a subprocess can use it: a shell script, a Node app, a Python service, a chat bot, an IDE plugin. -Python applications can embed the engine library in-process instead. +The engine is a library: a Python application adds it as a dependency and calls it in-process. +Everything else reaches the same engine by spawning it or calling its HTTP face: a shell script, a Node app, a chat bot, an IDE plugin. Public integrations run opencode, paperclip, and NanoClaw on it: see [who has integrated it](docs/ECOSYSTEM.md). @@ -120,7 +120,7 @@ with spawn_agent_sync(session_id="chat-42", approval={"mode": "yes"}) as handle: raise AaaError(event.code, event.message) ``` -Python hosts can skip the subprocess entirely and embed `amplifier_agent_lib` in-process. Node hosts, HTTP callers, and anyone building their own adapter should start at the [**integration guide**](docs/INTEGRATION.md), which covers all five surfaces, the wire protocol, session continuity, and approval policy for services. +A Python host should embed `amplifier_agent_lib` directly rather than spawning anything. Start at the [**integration guide**](docs/INTEGRATION.md): it opens with a complete working embedding, then covers the wrappers for hosts that cannot embed, the wire protocol, session continuity, and approval policy for services. ## Architecture at a glance @@ -129,23 +129,27 @@ Amplifier-agent is standalone. You do not need the Amplifier CLI, bundles, or an ``` Host Application ← your code ↓ -Adapter (host-specific glue) ← per-host integration + ├─ import ───────────────────────────────────┐ Python hosts + │ │ + └─ subprocess / HTTP │ everyone else + ↓ │ + amplifier-agent CLI / HTTP face │ ← this repo + (argv in, JSON envelope out) │ + ↓ │ + ┌──────────────────────────────────────────┘ ↓ -Language Wrapper (TypeScript or Python) ← typed SDK - ↓ subprocess (argv in / JSON envelope out, or in-process) -amplifier-agent CLI ← this repo - ↓ (in-process) -amplifier_agent_lib (engine library) ← this repo +amplifier_agent_lib (the engine) ← this repo ``` -The CLI binary is a thin I/O adapter on top of `amplifier_agent_lib`. The library is transport-free, so Python hosts can skip the subprocess entirely. +`amplifier_agent_lib` is the engine and the contract. The CLI binary is an argv and stdio adapter over it, the HTTP face is an OpenAI-compatible adapter over it, and the TypeScript and Python SDKs are subprocess clients for hosts that cannot import Python in-process. The library is transport-free, so a Python host skips every one of those layers. ## Documentation | Document | Covers | |---|---| | [Install](docs/INSTALL.md) | Install, pin, update, uninstall, offline and CI notes | -| [Integration guide](docs/INTEGRATION.md) | **Start here to embed the engine.** TypeScript SDK, Python SDK, in-process library, HTTP face, wire protocol | +| [Integration guide](docs/INTEGRATION.md) | **Start here to build on the engine.** Embedding the library, then the TypeScript and Python SDKs, HTTP face, wire protocol | +| [Engine API](docs/spec/engine-api.md) | The library contract: turn assembly, `Engine` lifecycle, protocol points, spawn | | [Configuration](docs/CONFIGURATION.md) | Providers, credentials, approval policy, host config file | | [CLI reference](docs/CLI.md) | Every command and flag, output and display modes, session continuity, skills and modes | | [Architecture](docs/ARCHITECTURE.md) | How the layers fit together and what runs where | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f965204c..68a678cd 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -6,14 +6,19 @@ gets framed, how the work gets checked, when a human is genuinely needed, which which piece of work. Swap the harness, swap the model, keep the layer. This repository is the engine for that layer. It wraps the amplifier-foundation -bundle/session kernel in an opinionated, vendored agent environment and exposes it through -two front ends, because there are exactly two ways the layer is consumed: +bundle/session kernel in an opinionated, vendored agent environment and publishes it as a +library, with two front ends over that library for consumers who cannot import it: ``` -one inside an application you are building -two inside a harness you already use +zero imported directly, in a Python application <- the library, the contract +one inside an application you are building <- CLI front end + wrapper SDKs +two inside a harness you already use <- HTTP front end ``` +Paths one and two exist because a Node app or an existing harness cannot import Python +in-process. They are transport, not capability: everything they can do, the library does +without the boundary. + ![Architecture](architecture/architecture.png) Source: `architecture/architecture.dot`. Regenerate with: @@ -46,6 +51,13 @@ Neither front end carries agent behavior. Both are adapters over the same `amplifier_agent_lib` runtime, which is what makes "the same expertise on both paths" mechanical rather than aspirational. +**A Python application skips both front ends.** The front ends exist to carry the runtime +across a process or protocol boundary, and a Python host has no such boundary to cross: it +imports `amplifier_agent_lib` and calls it directly, supplying its own display and approval +objects instead of parsing a stream. The library is the contract; the front ends are how +everything else reaches it. See [`spec/engine-api.md`](spec/engine-api.md) for the library +API and [`INTEGRATION.md`](INTEGRATION.md) for a working embedding. + ## The three packages ``` diff --git a/docs/ECOSYSTEM.md b/docs/ECOSYSTEM.md index 001c7200..0a8e7581 100644 --- a/docs/ECOSYSTEM.md +++ b/docs/ECOSYSTEM.md @@ -27,4 +27,5 @@ Runs [NanoClaw](https://nanoclaw.dev), which routes chat channels into per-agent ## Building your own -Start at the [integration guide](INTEGRATION.md). It covers all five surfaces (TypeScript SDK, Python SDK, in-process library, HTTP face, raw CLI contract) and ends with a checklist for a new integration. +Start at the [integration guide](INTEGRATION.md). It opens with embedding the engine library, the primary surface, then covers the wrappers for hosts that cannot embed (TypeScript SDK, Python SDK, HTTP face, raw CLI contract), and ends with a checklist for each path. +Each reaches the engine out-of-process for its own reason: opencode is an existing harness, nanoclaw is Node, and paperclip runs the engine in a container. A new host should reach for the library first. diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index 6f7030d3..dd2ef881 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -1,34 +1,188 @@ # Integration guide -How to drive `amplifier-agent` from your own software. +How to build software on `amplifier-agent`. -The engine runs **one turn per invocation** and exits. Continuity across turns comes from a session ID, not from a long-lived process. Everything below is a different way of delivering a prompt to that same engine. +**The library is the product.** `amplifier_agent_lib` is the engine; every other surface in this repo is a convenience wrapper over it. The CLI is an argv and stdio adapter, the HTTP face is an OpenAI-compatible adapter, and the TypeScript and Python SDKs are subprocess clients for hosts that cannot import Python in-process. The contract is the edge of the library, and the wrappers exist to reach it from places that cannot. + +Start with the library. Reach for a wrapper when your host genuinely cannot embed it. + +The engine runs **one turn per invocation** and exits. Continuity across turns comes from a session ID, not from a long-lived process. That holds for the library too: an embedded `Engine` boots, takes a turn, and shuts down, and the next turn resumes by session ID. ## Before you start -`amplifier-agent` is a standalone binary. You do not need the Amplifier CLI, bundles, or any other repository in the `microsoft/amplifier*` family, and none of them is a substitute for it here. +`amplifier-agent` is self-contained. You do not need the Amplifier CLI, bundles, or any other repository in the `microsoft/amplifier*` family, and none of them is a substitute for it here. Use it when your software needs to run an agent: a loop with tools, file access, sub-agents, and/or multi-turn state. It also works for plain LLM calls, where you get routing across nine providers behind one interface. -Then pick a surface below, install the engine ([INSTALL.md](INSTALL.md)), and finish with the [checklist](#checklist-for-a-new-integration). +## Install + +One distribution ships the library and the `amplifier-agent` binary together. Installing either gets you both. + +Git is the supported and tested channel. PyPI artifacts are published on every tag but nothing in this repo exercises that path, so treat it as unverified. + +```bash +uv add "amplifier-agent @ git+https://github.com/microsoft/amplifier-agent" +``` + +which records in your `pyproject.toml`: + +```toml +dependencies = ["amplifier-agent"] + +[tool.uv.sources] +amplifier-agent = { git = "https://github.com/microsoft/amplifier-agent" } +``` + +Or into an existing environment: + +```bash +uv pip install "git+https://github.com/microsoft/amplifier-agent" +``` + +If your host does not embed the library and only spawns the binary, the installer script is the lighter path. See [INSTALL.md](INSTALL.md). Install as the same user that runs your host process, since a host spawning a subprocess inherits that user's `PATH`. + +Either way, the binary doubles as your setup and diagnostics surface. Use it to verify the environment before writing integration code, whether or not it is on your runtime path: + +```bash +amplifier-agent doctor # env, providers, paths, bundle cache +amplifier-agent auth set anthropic sk-ant-... +amplifier-agent models list # provider-namespaced model IDs +amplifier-agent version # engine and wire protocol versions +``` + +Credentials resolve from the environment first, then `~/.amplifier-agent/credentials.json`. See [CONFIGURATION.md](CONFIGURATION.md). + +## Embedding the library + +The engine imports and runs in your process. No subprocess, no wire protocol, no envelope parsing, and the display and approval systems are your own objects rather than a stream you have to parse. + +This is a complete, working turn: + +```python +import asyncio +import sys + +from amplifier_agent_cli.provider_sources import inject_provider, inject_routing_matrix +from amplifier_agent_lib import __version__ +from amplifier_agent_lib._runtime import make_turn_handler +from amplifier_agent_lib.bundle.cache import load_and_prepare_cached +from amplifier_agent_lib.engine import Engine +from amplifier_agent_lib.protocol import PROTOCOL_VERSION, server_default_capabilities +from amplifier_agent_lib.protocol_points.defaults_cli import ( + CliApprovalSystem, + CliDisplaySystem, +) + + +async def main() -> None: + prepared = await load_and_prepare_cached(aaa_version=__version__) + + # Clear the catalog stubs the bundle declares, then inject the provider you + # want. inject_provider is a no-op if any provider is already mounted, so + # skipping the clear silently discards your injection. + prepared.mount_plan["providers"] = [] + inject_provider(prepared, "anthropic") + inject_routing_matrix(prepared, "anthropic") + + handler = make_turn_handler( + prepared, + cwd="/path/to/agent/workdir", + is_resumed=False, + workspace="my-app", + ) + + engine = Engine( + turn_handler=handler, + protocol_points={ + "approval": CliApprovalSystem(mode="yes"), + "display": CliDisplaySystem(stream=sys.stderr, verbosity="quiet"), + }, + ) + + await engine.boot( + { + "protocolVersion": PROTOCOL_VERSION, + "clientInfo": {"name": "my-app", "version": "1.0.0"}, + "capabilities": dict(server_default_capabilities()), + "sessionId": "chat-42", + "resume": False, + "cwd": "/path/to/agent/workdir", + }, + bundle_override=prepared, + ) + + try: + result = await engine.submit_turn( + {"sessionId": "chat-42", "turnId": "turn-1", "prompt": "Hello, agent."} + ) + print(result["reply"]) + finally: + await engine.shutdown() + + +asyncio.run(main()) +``` + +`submit_turn` returns eight keys: + +```python +{ + "reply": "...", "turnId": "turn-1", "sessionId": "chat-42", + "tokensIn": 12776, "tokensOut": 4, + "cacheReadTokens": 11904, "cacheWriteTokens": 0, + "costUsd": Decimal("0.0062472"), +} +``` -## Pick a surface +Usage comes off the return value directly. You do not need the CLI envelope to account for tokens or cost. `costUsd` is a `Decimal`, so `json.dumps(result)` raises `TypeError` unless you pass `default=str`. It is `None` when the provider reported no cost, which is not the same as zero. The numbers depend on prompt-cache state, so the same prompt twice will not report the same split. -| You are writing | Use | Section | +### Choosing a provider + +Discover which providers have resolvable credentials on this machine, then pick one deliberately: + +```python +from amplifier_agent_cli.provider_sources import enumerate_resolvable_providers + +available = enumerate_resolvable_providers() # e.g. ['anthropic', 'openai', 'gemini'] +``` + +Pick from that list against your own preference order rather than taking the first entry, since credential resolution says only that a key was found. To carry host configuration into the provider (`default_model`, `effort`, and similar), pass `provider_config_from_host(host_config)` as `inject_provider(..., extra_config=...)`. + +### Multi-turn and continuity + +Continuity comes from the session ID and the persisted transcript, not from keeping an `Engine` alive. Calling `submit_turn` twice on one booted `Engine` succeeds, but the second turn does **not** see the first: each turn builds its own context from the transcript on disk. + +Build one `Engine` per turn and pass `is_resumed=True` for every turn after the first, reusing the same `sessionId` and `workspace`: + +```python +handler = make_turn_handler(prepared, cwd=..., is_resumed=True, workspace="my-app") +``` + +Session state lives under `$AMPLIFIER_AGENT_HOME/state/workspaces//sessions//`, and continuity is per `(workspace, session-id)` pair. Set `workspace` explicitly. Without it, sessions are scoped to the working directory, so a host that runs from varying directories sees its sessions fragment. + +### What to know before you ship + +- **Everything is async.** `load_and_prepare_cached`, `boot`, `submit_turn`, and `shutdown` are all coroutines. There is no sync facade. +- **`import amplifier_agent_lib` sets `AMPLIFIER_HOME`** in `os.environ`, unconditionally, at import time, overwriting any value you already set. If your host also uses `amplifier-foundation` or reads that variable, import order matters to you. +- **The first turn is slow.** A cold bundle cache is prepared on first use. A `prepared.pickle is corrupted (ModuleNotFoundError); rebuilding` warning on first run from a new environment is benign and self-healing; the cache is keyed by engine version and bundle digest, not by interpreter. +- **`CliApprovalSystem()` with no arguments declines everything.** Auto-approve is `mode="yes"`. +- **The packages ship no `py.typed`**, so type checkers treat the imported symbols as untyped. + +Normative contract: [`spec/engine-api.md`](spec/engine-api.md). Layer boundaries: [`ARCHITECTURE.md`](ARCHITECTURE.md). + +## When you cannot embed + +| Your host | Use | Section | |---|---|---| +| Python, in-process | `amplifier_agent_lib` | [Embedding the library](#embedding-the-library) | | Node.js or TypeScript | `amplifier-agent-ts` npm package | [TypeScript SDK](#typescript-sdk) | -| Python, separate process | `amplifier-agent-py` wrapper | [Python SDK](#python-sdk) | -| Python, same process | `amplifier_agent_lib` directly | [In-process library](#in-process-library) | -| Anything that speaks HTTP | `amplifier-agent serve chat-completions` | [HTTP face](#http-face) | +| Python, needs process isolation | `amplifier-agent-py` wrapper | [Python SDK](#python-sdk) | +| Already speaks OpenAI chat completions | `amplifier-agent serve chat-completions` | [HTTP face](#http-face) | | A shell script, or a language with no SDK | The CLI contract | [Wire protocol](#wire-protocol) | -All five sit on the same engine. - -## Prerequisites - -The SDKs are **BYO-engine**: they have zero runtime dependencies and locate the `amplifier-agent` binary on `PATH`. Install the engine first ([INSTALL.md](INSTALL.md)), then the SDK for your language. +All of these sit on the same engine, and reach it by spawning the binary or calling the server rather than importing the library. -Install the engine as the same user that runs your host process. A host spawning a subprocess inherits that user's `PATH`. +The SDKs are **BYO-engine**: zero runtime dependencies, and they locate the `amplifier-agent` binary on `PATH` (or at `AMPLIFIER_AGENT_BIN`). ## TypeScript SDK @@ -62,7 +216,11 @@ Full API surface: [`wrappers/typescript/README.md`](../wrappers/typescript/READM ## Python SDK -Use this when you want process isolation between your host and the engine. For same-process embedding, see [in-process library](#in-process-library) instead. +A subprocess client for Python hosts that want process isolation between host and engine. If you do not need that isolation, [embed the library](#embedding-the-library) instead and skip the process boundary. + +```bash +uv add "amplifier-agent-py @ git+https://github.com/microsoft/amplifier-agent#subdirectory=wrappers/python-py" +``` ```python from amplifier_agent_py import AaaError, spawn_agent_sync @@ -82,13 +240,7 @@ with spawn_agent_sync( raise AaaError(event.code, event.message) ``` -An async variant (`spawn_agent`, returning `SessionHandle`) is also exported. Runnable examples: [`wrappers/python-py/examples/`](../wrappers/python-py/examples/) contains `sync_chat.py`, `async_chat.py`, and `diagnostic.py`. - -## In-process library - -`amplifier_agent_lib` is transport-free Python. A Python host can import it and skip the subprocess entirely, giving up process isolation between host and engine. The CLI binary is itself a thin I/O adapter over this library, so the two paths share all engine behavior. - -See [`ARCHITECTURE.md`](ARCHITECTURE.md) for the layer boundaries and [`spec/engine-api.md`](spec/engine-api.md) for the library contract. +An async variant (`spawn_agent`, returning `SessionHandle`) is also exported. Parameters mirror the TypeScript SDK one for one, and that symmetry is enforced by a conformance suite. Runnable examples: [`wrappers/python-py/examples/`](../wrappers/python-py/examples/) contains `sync_chat.py`, `async_chat.py`, and `diagnostic.py`. ## HTTP face @@ -116,6 +268,8 @@ Model ids on this surface are namespaced per provider, because a single model li ## Wire protocol +This section describes the **subprocess boundary**. None of it applies to an embedder: there is no argv, no envelope, and no exit code when you import the library. + Protocol version **`0.4.0`**, defined in `src/amplifier_agent_lib/protocol/methods.py`. Breaking changes bump it. Wrappers must pass `--protocol-version 0.4.0`; a mismatch returns `protocol_version_mismatch` and exits non-zero rather than silently misbehaving. The wrapper passes flags as argv. The engine writes one JSON envelope line to stdout on completion. @@ -125,6 +279,7 @@ The wrapper passes flags as argv. The engine writes one JSON envelope line to st | Flag | Type | Purpose | |---|---|---| | `PROMPT` | positional | The turn prompt | +| `--prompt-file` | path | Read the prompt from a UTF-8 file instead of the positional argument. Mutually exclusive with `PROMPT`; wrappers use it automatically for large prompts | | `--session-id` | str | Session ID for continuity | | `--workspace` | str | Workspace name for isolating session state | | `--resume` | flag | Resume from saved transcript | @@ -156,7 +311,7 @@ The wrapper passes flags as argv. The engine writes one JSON envelope line to st } ``` -`activeMode` echoes the `--mode` value for the turn, `null` when omitted. Under `--output text` (the default) stdout is the reply text only, which is easier to pipe into shell tooling. +The usage fields are the same numbers `submit_turn` returns to an embedder; the CLI does no arithmetic of its own. `activeMode` echoes the `--mode` value for the turn, `null` when omitted. Under `--output text` (the default) stdout is the reply text only, which is easier to pipe into shell tooling. **Streams are strictly separated.** Diagnostic events (tool calls, thinking, progress) go to **stderr** only. Stdout carries the envelope or reply so callers can parse it without filtering. Under `--display ndjson`, stderr emits one JSON-RPC notification per line for wrapper consumption. @@ -164,7 +319,9 @@ Normative contracts: [`spec/wire-protocol.md`](spec/wire-protocol.md), [`spec/en ## Session continuity -Sessions persist as transcript JSONL under `$AMPLIFIER_AGENT_HOME/state/workspaces//sessions//`. Continuity is per `(workspace, session-id)` pair. +Sessions persist as transcript JSONL under `$AMPLIFIER_AGENT_HOME/state/workspaces//sessions//`. Continuity is per `(workspace, session-id)` pair, on every surface. + +Embedders control this with `is_resumed` on `make_turn_handler` and `resume` in the boot params. Subprocess callers use flags: ```bash amplifier-agent run -y --session-id chat-42 "My favorite color is blue." @@ -178,7 +335,9 @@ Pass `--workspace ` to isolate session state per project. Without it, sess ## Approval policy -A host that spawns the engine headlessly **must declare an approval policy**, or the run refuses to start: `-y`, `-n`, or `approval.mode` in a host config file. With none of those and no TTY, the run exits 2 with `approval_unconfigured` rather than auto-denying every tool call and still exiting 0, which looked like success while doing no work. +An embedder supplies an `ApprovalSystem` object directly, so the policy is whatever that object does. `CliApprovalSystem(mode="yes")` auto-approves, `mode="no"` auto-declines, and the no-argument default declines. Implement the protocol yourself for a real human-in-the-loop channel; it must honor `timeoutMs` and return `{"action": "cancel"}` on timeout. + +A host that **spawns** the engine headlessly must declare an approval policy on argv or in a config file, or the run refuses to start: `-y`, `-n`, or `approval.mode`. With none of those and no TTY, the run exits 2 with `approval_unconfigured` rather than auto-denying every tool call and still exiting 0, which looked like success while doing no work. Both SDKs accept an approval option and pass it through, so you normally set it there rather than on argv. Full precedence in [CONFIGURATION.md](CONFIGURATION.md#approval-policy). @@ -197,7 +356,19 @@ A subprocess host typically writes one config file per agent instance and passes This is the standard pattern for multi-tenant hosts: one directory per agent, holding its MCP server list, its skills, and its config. The top level of the file is closed, so an unknown key is an error rather than a warning. Full schema in [CONFIGURATION.md](CONFIGURATION.md) and [`spec/host-config.md`](spec/host-config.md). -## Checklist for a new integration +An embedder can load the same file with `amplifier_agent_lib.config.load_config` and pass the result to `make_turn_handler(host_config=...)`, or skip the file entirely and construct the dict in code. + +## Checklist for embedding + +1. Declare `amplifier-agent` as a dependency, and confirm the environment with `amplifier-agent doctor`. +2. Choose a provider from `enumerate_resolvable_providers()` against your own preference order, and fail loudly when the list is empty. +3. Clear `prepared.mount_plan["providers"]` before `inject_provider`, or your injection is discarded. +4. Set `workspace` so session state does not follow the working directory. +5. Build one `Engine` per turn and pass `is_resumed=True` after the first, reusing the session ID. +6. Supply an `ApprovalSystem` that matches your trust model. The default declines. +7. Record `__version__` and the usage fields from every turn alongside your own logs. + +## Checklist for a subprocess integration 1. Install the engine as the user that runs your host, and confirm with `amplifier-agent doctor`. 2. Pin `--protocol-version` to the version your SDK targets. Fail loudly on mismatch. @@ -209,6 +380,7 @@ This is the standard pattern for multi-tenant hosts: one directory per agent, ho ## Reference +- Library contract: [`spec/engine-api.md`](spec/engine-api.md) - Architecture and layer boundaries: [`ARCHITECTURE.md`](ARCHITECTURE.md) - Normative specifications index: [`SPEC.md`](SPEC.md) - Configuration: [`CONFIGURATION.md`](CONFIGURATION.md) diff --git a/docs/LAYERS_AND_RELEASES.md b/docs/LAYERS_AND_RELEASES.md index 305b315e..67d69dcb 100644 --- a/docs/LAYERS_AND_RELEASES.md +++ b/docs/LAYERS_AND_RELEASES.md @@ -8,15 +8,16 @@ Scope: which layer a change lands in, and what that means for releasing. The ins ## TL;DR -`amplifier-agent` is a **per-turn stdio subprocess** that wraps the Amplifier kernel plus a fixed bundle of modules, with an **optional OpenAI-compatible HTTP server** for hosts that already speak chat-completions. Hosts integrate through one of three surfaces: +`amplifier-agent` is an **engine library** wrapping the Amplifier kernel plus a fixed bundle of modules, exposed additionally as a **per-turn stdio subprocess** and an **optional OpenAI-compatible HTTP server** for hosts that cannot import it. Hosts integrate through one of four surfaces: | Surface | Package | For | |---|---|---| -| Python SDK | `amplifier-agent-py` (PyPI) | Python hosts | +| Engine library | `amplifier_agent_lib` (in the `amplifier-agent` distribution) | Python hosts, in-process. **The primary surface.** | | TypeScript SDK | `amplifier-agent-ts` (npm) | Node / TypeScript hosts | +| Python SDK | `amplifier-agent-py` (PyPI) | Python hosts that need process isolation | | HTTP server | `amplifier-agent serve chat-completions` | Hosts that already speak the chat-completions REST shape (e.g. opencode) | -All three sit on the same engine. The same release of `amplifier-agent` powers all three. +All four sit on the same engine, and the latter three reach it by spawning the binary or calling the server. The same release of `amplifier-agent` powers all four. ## The Layer Stack @@ -25,22 +26,25 @@ All three sit on the same engine. The same release of `amplifier-agent` powers a | Host application | | (nanoclaw fork, paperclip fork, opencode, your app, ...) | +---------------------------------------------------------------------+ - | - v -+---------------------------------------+-----------------------------+ -| Adapter | HTTP bridge | -| (per-host integration code, | (e.g. amplifier-app- | -| uses one of the SDKs) | opencode) | -+---------------------------------------+-----------------------------+ - | | - v v -+------------------------------+ +------------------------------------+ -| Client SDK | | amplifier-agent serve | -| amplifier-agent-py (PyPI) | | chat-completions | -| amplifier-agent-ts (npm) | | FastAPI HTTP face (POC) | -+------------------------------+ +------------------------------------+ - | | - +--------------+-------------------+ + | | | + | v v + | +---------------------------+ +----------------------------+ + | | Adapter | | HTTP bridge | + | | (per-host integration | | (e.g. amplifier-app- | + | | code, uses one of | | opencode) | + | | the SDKs) | | | + | +---------------------------+ +----------------------------+ + | | | + | v v + | +---------------------------+ +----------------------------+ + | | Client SDK | | amplifier-agent serve | + | | amplifier-agent-py | | chat-completions | + | | amplifier-agent-ts (npm) | | FastAPI HTTP face (POC) | + | +---------------------------+ +----------------------------+ + | | | + direct import | +--------------+-------------------+ + (Python hosts) | | + +------------------------------+ v +---------------------------------------------------------------------+ | amplifier-agent (installed from git) | diff --git a/docs/SPEC.md b/docs/SPEC.md index cd7c62d1..d937708b 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -11,6 +11,9 @@ they stay true regardless of how the engine is implemented internally. ### Surfaces callers drive ``` +spec/engine-api.md the library API for embedders, and the primary surface: + turn assembly, Engine lifecycle, the two protocol points, + the transport-free invariant, spawn policy spec/cli.md the command surface: run and every admin subcommand, flags, mutual exclusions, and the flags that stay removed spec/envelope-and-errors.md the stdout envelope, the error envelope, the full error @@ -28,8 +31,6 @@ spec/wire-protocol.md JSON-RPC over NDJSON, PROTOCOL_VERSION and the equality rule, the display event taxonomy, conformance fixtures. References the generated schemas, does not restate them. -spec/engine-api.md the library API for embedders: Engine lifecycle, the two - protocol points, the transport-free invariant, spawn policy ``` ### Configuration and state diff --git a/docs/spec/engine-api.md b/docs/spec/engine-api.md index 633897e8..b67da634 100644 --- a/docs/spec/engine-api.md +++ b/docs/spec/engine-api.md @@ -2,14 +2,77 @@ ## Scope -The importable library API of `amplifier_agent_lib` for embedders: the `Engine` lifecycle and its -exception types, the two protocol point interfaces and their shipped implementations, the stream -ownership guarantee, and the sub-agent spawn surface. Does not cover the JSON-RPC method shapes -(see `wire-protocol.md`), the stdout envelope (see `envelope-and-errors.md`), or the CLI flags -(see `cli.md`). +The importable library API of `amplifier_agent_lib` for embedders: the turn assembly sequence, the +`Engine` lifecycle and its exception types, the two protocol point interfaces and their shipped +implementations, the stream ownership guarantee, and the sub-agent spawn surface. Does not cover +the JSON-RPC method shapes (see `wire-protocol.md`), the stdout envelope (see +`envelope-and-errors.md`), or the CLI flags (see `cli.md`). Everything named here is public API. Names, signatures, and exception types are the contract. +Two symbols on the assembly path are exceptions, called out where they appear: `make_turn_handler` +lives in a private module, and provider injection lives in the `amplifier_agent_cli` package. Both +are load-bearing for any embedder and are documented here because the API is not usable without +them. + +## Assembling a turn + +`Engine` does not construct its own turn handler and knows nothing about providers. An embedder +assembles the pieces in this order: + +```python +prepared = await load_and_prepare_cached(aaa_version=__version__) + +prepared.mount_plan["providers"] = [] +inject_provider(prepared, provider_name, extra_config=None) +inject_routing_matrix(prepared, provider_name) + +handler = make_turn_handler(prepared, cwd=..., is_resumed=..., workspace=...) +engine = Engine(turn_handler=handler, + protocol_points={"approval": ..., "display": ...}) +await engine.boot(init_params, bundle_override=prepared) +result = await engine.submit_turn({"sessionId": ..., "turnId": ..., "prompt": ...}) +await engine.shutdown() +``` + +``` +amplifier_agent_lib.bundle.cache + async load_and_prepare_cached(aaa_version: str) -> PreparedBundle + +amplifier_agent_lib._runtime (private module; no public alias exists) + make_turn_handler(prepared: PreparedBundle, *, cwd: str | None, is_resumed: bool, + host_config: dict | None = None, workspace: str | None = None, + mode: str | None = None) -> TurnHandler + +amplifier_agent_cli.provider_sources (CLI package; no lib equivalent exists) + enumerate_resolvable_providers() -> list[str] + inject_provider(prepared, provider_name, model_override=None, + effort_override=None, extra_config=None) -> None + inject_routing_matrix(prepared, provider_name) -> None + provider_config_from_host(host_config) -> dict | None +``` + +`cwd` and `is_resumed` are required keyword arguments on `make_turn_handler` and have no defaults. + +`inject_provider` is a no-op when `prepared.mount_plan["providers"]` is already non-empty. The +vendored `bundle.md` declares a catalog stub for every provider so cold-prepare can install them, +so the clear is required: without it the injection is silently discarded and the turn runs on the +stub. + +`enumerate_resolvable_providers()` reports which providers have credentials that actually resolve, +walking the env-var then `credentials.json` chain. It answers "which providers could run", not +"which provider should run"; the caller picks. + +## Turn boundaries + +One `Engine` serves one turn. `submit_turn` may be called repeatedly on a booted `Engine` and will +not raise, but each turn builds its own context from the persisted transcript, so a second turn on +the same `Engine` does not see the first. Continuity is a function of `sessionId`, `workspace`, and +`is_resumed`, not of process or object lifetime. + +The supported multi-turn shape is a fresh handler and `Engine` per turn, reusing the session id and +workspace, with `is_resumed=True` on every turn after the first. + ## Engine ```python @@ -46,17 +109,46 @@ shutdown() idempotent, always returns {}, never raises `EngineNotBootedError` and `EngineShutdownError` both subclass `RuntimeError`. `boot()` performs the protocol version check (see `wire-protocol.md`), resolves the prepared -bundle, negotiates capabilities, and caches the `InitializeResult`. `bundle_override` supplies a -prepared bundle instead of the resolved one and exists for tests; production callers leave it -`None`. +bundle, negotiates capabilities, and caches the `InitializeResult`. + +`boot` reads its params leniently: every key is optional, and the only param that can fail the call +is a `protocolVersion` that is present and does not match. Omitting it skips the check entirely. +`submit_turn` indexes its three keys directly, so `sessionId`, `turnId`, and `prompt` are all +required and a missing one raises `KeyError`. + +`bundle_override` supplies a prepared bundle instead of resolving one. It does **not** determine +which bundle serves the turn: the turn runs on the bundle closed over by `make_turn_handler`, so +provider injection takes effect either way. Passing it avoids a second, redundant +`load_and_prepare_cached()` inside `boot()`, which on a cold cache costs real time. Pass the same +`PreparedBundle` you gave `make_turn_handler`. `dispatch()` accepts `agent/initialize`, `turn/submit`, and `agent/shutdown`, and raises `ValueError` for any other method name. See `wire-protocol.md` for the divergence between these names and the published schema set. -Contract caveat: a `TurnSubmitResult` carries exactly `reply`, `turnId`, and `sessionId`. The -`finalEvent` key is declared as optional on the type and in the published schema, but nothing ever -populates it. Do not wait for it. +A `TurnSubmitResult` carries eight keys: + +```python +{ + "reply": str, "turnId": str, "sessionId": str, + "tokensIn": int, "tokensOut": int, + "cacheReadTokens": int, "cacheWriteTokens": int, + "costUsd": Decimal | None, +} +``` + +The five usage fields are summed from the turn's `usage` display events by the `UsageAccumulator` +that `Engine` wraps around the injected display point. They are the same numbers the CLI reports as +`metadata` on the stdout envelope; there is exactly one place they are summed. An embedder reads +them off the return value and needs no envelope. + +`tokensIn` is the CHARGED input: gross input plus cache writes. `cacheReadTokens` is a reported +subset, not an addend. `costUsd` is a `Decimal`, so `json.dumps` on the result requires +`default=str`; it is `None` when the provider reported no cost, which is distinct from zero. The +cache split varies with prompt-cache state, so identical prompts do not report identical numbers. + +Contract caveat: the `finalEvent` key is declared as optional on the type and in the published +schema, but nothing ever populates it. Do not wait for it. ### TurnContext and TurnHandler @@ -195,10 +287,14 @@ Observable guarantees: them from the working directory, so a delegate's state lands in the parent's workspace bucket. With no workspace set on the parent, nothing is propagated. +A child's spend is bridged onto the parent session's cost channel after the child completes +successfully, so an embedder's per-turn usage totals include delegated work. A failed delegation's +spend is deliberately not bridged. + Unsupported in this version, and observable as failures or as absent behavior: recursive spawn from -a child (grandchild delegation fails with the delegate tool's own error), cost bridging from child -to parent, display nesting, provider preference plumbing beyond plain config inclusion, session -resume for a child, and capture of a child's status or turn count. +a child (grandchild delegation fails with the delegate tool's own error), display nesting, provider +preference plumbing beyond plain config inclusion, session resume for a child, and capture of a +child's status or turn count. ## Non-goals @@ -206,7 +302,10 @@ resume for a child, and capture of a child's status or turn count. on any public config object. A host-supplied spawn function could resolve the wrong bundle, workspace, or agent overlay, and the failure would surface as a sub-agent producing wrong output rather than as an error. -- **No second session-factory path.** `Engine.boot()` once per process is the only path. +- **No second session-factory path for embedders.** `Engine.boot()` is the only supported entry. + `amplifier_agent_http` reaches the runtime directly through a private path of its own; that is an + internal arrangement inside this repo, not a second public API, and it is not available to or + supported for embedders. - **No host-facing mount plan.** The bundle manifest is sealed; embedders do not compose it. - **No mid-turn config mutation.** Config is read once at startup. - **No kernel surface.** Everything here is app layer. diff --git a/skills/amplifier-agent/SKILL.md b/skills/amplifier-agent/SKILL.md index a6505aab..764bcaa9 100644 --- a/skills/amplifier-agent/SKILL.md +++ b/skills/amplifier-agent/SKILL.md @@ -3,28 +3,31 @@ name: amplifier-agent description: >- Build software on amplifier-agent, the Microsoft agent engine that other software runs on. Use when (1) adding an AI agent, agent loop, or chat backend - to an app, service, CLI, or bot, (2) integrating amplifier-agent from - TypeScript/Node, Python, HTTP, or a shell script, (3) choosing between the - TypeScript SDK, Python SDK, in-process library, HTTP face, and raw CLI - contract, (4) debugging a host that spawns the engine: approval_unconfigured, - protocol_version_mismatch, binary_not_found, lost session continuity, or - stdout/stderr parsing. Triggers on "amplifier-agent", "amplifier agent", - "amplifier-agent-ts", "amplifier_agent_py", "spawnAgent", "spawn_agent_sync", - "serve chat-completions", "amplifier_agent_lib", "add an agent to my app". + to an app, service, CLI, or bot, (2) embedding the engine library in a Python + host, or integrating amplifier-agent from TypeScript/Node, HTTP, or a shell + script, (3) choosing between the in-process library, the TypeScript and Python + SDKs, the HTTP face, and the raw CLI contract, (4) debugging an integration: + provider_not_configured, approval_unconfigured, protocol_version_mismatch, + binary_not_found, lost session continuity, or stdout/stderr parsing. Triggers + on "amplifier-agent", "amplifier agent", "amplifier_agent_lib", "embed the + agent engine", "amplifier-agent-ts", "amplifier_agent_py", "spawnAgent", + "spawn_agent_sync", "serve chat-completions", "add an agent to my app". license: MIT metadata: author: microsoft - version: "0.1.0" + version: "0.2.0" repository: https://github.com/microsoft/amplifier-agent --- # Integrating amplifier-agent -`amplifier-agent` is an agent engine that other software runs on. Give it a prompt and it runs the full loop, with tools, sub-agents, skills, and MCP, then returns a result. Anything that can spawn a subprocess can use it; Python hosts can embed the engine library in-process instead. +`amplifier-agent` is an agent engine that other software runs on. Give it a prompt and it runs the full loop, with tools, sub-agents, skills, and MCP, then returns a result. + +**The library is the product.** `amplifier_agent_lib` is the engine. The CLI, the HTTP face, and the TypeScript and Python SDKs are all convenience wrappers over it. A Python host imports the library. Everything else spawns the binary or calls the server, because it cannot import Python in-process. Reach for it when the project needs an *agent* (a tool loop, file access, sub-agents, multi-turn state) rather than a single completion. You can also use it for plain LLM calls, with routing across nine providers behind one interface. -**The engine runs one turn per invocation and exits.** Continuity across turns comes from a session id, not from a long-lived process. Every surface below is a different way of delivering a prompt to that same engine. +**The engine runs one turn per invocation and exits.** Continuity across turns comes from a session id, not from a long-lived process. This is true when embedding too: one `Engine` per turn, resumed by session id. ## Before writing code @@ -33,6 +36,7 @@ Confirm every flag, field, and option name against the docs below or a local ins | Need | Source | |---|---| | Integration surfaces | | +| Library contract (embedding) | | | Install, pin, update | | | Providers, credentials, host config | | | Every command and flag | | @@ -41,9 +45,15 @@ Confirm every flag, field, and option name against the docs below or a local ins Against a local install, the binary is authoritative: `amplifier-agent version` prints the engine and wire protocol versions, `amplifier-agent doctor` reports env, providers, paths, and bundle cache, and `--help` on any command prints the real flags. If you cannot confirm something from the docs, the binary, or the SDK type definitions, say so rather than guessing. -## Install the engine first +## Install + +One distribution ships the library and the `amplifier-agent` binary together. Git is the supported and tested channel; PyPI artifacts are published on every tag but nothing exercises that path, so do not route a host through it without verifying first. + +```bash +uv add "amplifier-agent @ git+https://github.com/microsoft/amplifier-agent" +``` -Every surface needs the engine. The SDKs are **BYO-engine**: they have zero runtime dependencies and locate the `amplifier-agent` binary on `PATH` (or at `AMPLIFIER_AGENT_BIN`). +For a host that only spawns the binary, the installer script is lighter: ```bash curl -fsSL https://raw.githubusercontent.com/microsoft/amplifier-agent/main/install.sh | bash @@ -52,18 +62,113 @@ amplifier-agent doctor The installer needs `uv` and `curl` and will not bootstrap them silently; it tells you which is missing and stops. If `uv` is absent, install it first with `curl -LsSf https://astral.sh/uv/install.sh | sh`, then re-run. Pin a release by appending `-s -- --tag v0.12.0`, and add `--yes` in CI or a Dockerfile to skip the prompt. -Install as **the same user that runs the host process**; a host spawning a subprocess inherits that user's `PATH`. `amplifier-agent doctor` is the check that the install actually works, so run it before writing any integration code. +For subprocess hosts, install as **the same user that runs the host process**; a host spawning a subprocess inherits that user's `PATH`. The SDKs are **BYO-engine**: zero runtime dependencies, locating the binary on `PATH` or at `AMPLIFIER_AGENT_BIN`. + +Either way, use the binary for setup and diagnostics even when it is not on your runtime path: `amplifier-agent doctor`, `amplifier-agent auth set `, `amplifier-agent models list`. Credentials are read from the environment, first match wins: `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `AZURE_OPENAI_API_KEY` plus `AZURE_OPENAI_ENDPOINT`, `OLLAMA_HOST`. GitHub Copilot is environment-only (`COPILOT_AGENT_TOKEN`, `COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, `GITHUB_TOKEN`). ChatGPT (`openai-chatgpt`) has no credential env var at all: it authenticates via OAuth device-code, caching tokens to `~/.amplifier/openai-chatgpt-oauth.json`. The `chat-completions` provider is environment-only too, for any OpenAI Chat Completions-compatible endpoint (llama.cpp, vLLM, LM Studio, LocalAI, and similar): `CHAT_COMPLETIONS_BASE_URL` (required) plus optional `CHAT_COMPLETIONS_API_KEY`. The `vllm` provider is the same shape, for a self-hosted or remote vLLM server via its OpenAI-compatible Responses API: `VLLM_BASE_URL` (required) plus optional `VLLM_API_KEY`. Or store a static key with `amplifier-agent auth set anthropic sk-ant-...` (not supported for github-copilot or openai-chatgpt). -## Pick a surface +## Embedding the library + +A Python host imports the engine and runs it in-process. No subprocess, no argv, no envelope parsing. Display and approval are your own objects rather than streams to parse, and token and cost accounting come back on the return value. + +This is a complete, working turn: + +```python +import asyncio +import sys + +from amplifier_agent_cli.provider_sources import inject_provider, inject_routing_matrix +from amplifier_agent_lib import __version__ +from amplifier_agent_lib._runtime import make_turn_handler +from amplifier_agent_lib.bundle.cache import load_and_prepare_cached +from amplifier_agent_lib.engine import Engine +from amplifier_agent_lib.protocol import PROTOCOL_VERSION, server_default_capabilities +from amplifier_agent_lib.protocol_points.defaults_cli import ( + CliApprovalSystem, + CliDisplaySystem, +) + + +async def main() -> None: + prepared = await load_and_prepare_cached(aaa_version=__version__) + + prepared.mount_plan["providers"] = [] # load-bearing, see rule 2 + inject_provider(prepared, "anthropic") + inject_routing_matrix(prepared, "anthropic") + + handler = make_turn_handler( + prepared, cwd="/path/to/workdir", is_resumed=False, workspace="my-app" + ) + engine = Engine( + turn_handler=handler, + protocol_points={ + "approval": CliApprovalSystem(mode="yes"), + "display": CliDisplaySystem(stream=sys.stderr, verbosity="quiet"), + }, + ) + await engine.boot( + { + "protocolVersion": PROTOCOL_VERSION, + "clientInfo": {"name": "my-app", "version": "1.0.0"}, + "capabilities": dict(server_default_capabilities()), + "sessionId": "chat-42", + "resume": False, + "cwd": "/path/to/workdir", + }, + bundle_override=prepared, + ) + try: + result = await engine.submit_turn( + {"sessionId": "chat-42", "turnId": "turn-1", "prompt": "Hello, agent."} + ) + print(result["reply"]) + finally: + await engine.shutdown() + + +asyncio.run(main()) +``` + +`submit_turn` returns `reply`, `turnId`, `sessionId`, `tokensIn`, `tokensOut`, `cacheReadTokens`, `cacheWriteTokens`, and `costUsd`. `costUsd` is a `Decimal` (so `json.dumps` needs `default=str`) and is `None` when the provider reported no cost, which is not zero. + +Pick a provider with `enumerate_resolvable_providers()` from `amplifier_agent_cli.provider_sources`, which returns the providers whose credentials actually resolve on this machine. Choose from that list against your own preference order rather than taking the first entry. To carry host configuration into the provider, pass `provider_config_from_host(host_config)` as `inject_provider(..., extra_config=...)`. + +Signatures worth pinning down before you write against them: + +``` +load_and_prepare_cached(aaa_version: str) -> PreparedBundle # async +make_turn_handler(prepared, *, cwd, is_resumed, # not async + host_config=None, workspace=None, mode=None) -> TurnHandler +Engine(*, turn_handler, protocol_points) # both required +Engine.boot(params, bundle_override=None) -> InitializeResult # async +Engine.submit_turn(params) -> TurnSubmitResult # async +Engine.shutdown(_params=None) -> AgentShutdownResult # async, never raises +``` + +`boot` reads its params leniently (every key optional; a *wrong* `protocolVersion` is the only param that fails it). `submit_turn` indexes `sessionId`, `turnId`, and `prompt` directly, so all three are required. + +Normative contract: [`docs/spec/engine-api.md`](https://github.com/microsoft/amplifier-agent/blob/main/docs/spec/engine-api.md). -| You are writing | Use | +## Rules that break embeddings when ignored + +1. **One `Engine` per turn, resumed by session id.** Calling `submit_turn` twice on one booted `Engine` succeeds but the second turn does not see the first: each turn rebuilds context from the persisted transcript. Build a fresh handler and `Engine` per turn with `is_resumed=True` after the first, reusing the same `sessionId` and `workspace`. +2. **Clear `prepared.mount_plan["providers"]` before `inject_provider`.** `bundle.md` declares catalog provider stubs, and `inject_provider` is a no-op when any provider is already mounted. Skip the clear and your injection is silently discarded. +3. **Set `workspace` explicitly.** Without it, sessions are scoped to the working directory, so a host running from varying directories sees its sessions fragment. Continuity is per `(workspace, session-id)`. +4. **`CliApprovalSystem()` with no arguments declines everything.** Auto-approve is `mode="yes"`, auto-decline is `mode="no"`. For a real human-in-the-loop channel, implement the `ApprovalSystem` protocol yourself; it must honor `timeoutMs` and return `{"action": "cancel"}` on timeout. +5. **`import amplifier_agent_lib` overwrites `os.environ["AMPLIFIER_HOME"]`** at import time, unconditionally, discarding any value you set. If your host also uses `amplifier-foundation` or reads that variable, this will surprise you. +6. **Everything on the turn path is async.** There is no sync facade in `amplifier_agent_lib`. +7. **The first turn is slow, and one warning is benign.** A cold bundle cache is prepared on first use. `prepared.pickle is corrupted (ModuleNotFoundError); rebuilding` on the first run from a new environment is expected and self-heals; the cache is keyed by engine version and bundle digest, not by interpreter. +8. **No `py.typed` ships**, so type checkers treat the imported symbols as untyped. + +## When you cannot embed + +| Your host | Use | |---|---| +| Python, in-process | `amplifier_agent_lib` (above) | | Node.js or TypeScript | `amplifier-agent-ts` npm package | -| Python, separate process | `amplifier-agent-py` wrapper | -| Python, same process | `amplifier_agent_lib` directly | -| Anything that speaks HTTP | `amplifier-agent serve chat-completions` | +| Python, needs process isolation | `amplifier-agent-py` wrapper | +| Already speaks OpenAI chat completions | `amplifier-agent serve chat-completions` | | A shell script, or a language with no SDK | The CLI contract | ## TypeScript SDK @@ -98,10 +203,9 @@ for await (const event of session.submit('Hello, agent.')) { ## Python SDK -Use this when you want process isolation between host and engine. +A subprocess client for Python hosts that need process isolation between host and engine. If you do not need that isolation, embed the library instead. ```bash -# Not on PyPI yet; install from the git source uv add "amplifier-agent-py @ git+https://github.com/microsoft/amplifier-agent#subdirectory=wrappers/python-py" ``` @@ -124,10 +228,6 @@ with spawn_agent_sync( `spawn_agent` is the async variant, returning a handle whose `submit()` is an async iterator; call `await handle.dispose()` when done. Event fields are snake_case here (`session_id`, `correlation_id`, `stderr_tail`). Parameters mirror the TypeScript SDK one for one, and that symmetry is enforced by a conformance suite. -## In-process library - -`amplifier_agent_lib` is transport-free Python, and the CLI binary is a thin I/O adapter over it, so both paths share all engine behavior. You give up process isolation. The public contract is the `Engine` class: `boot()`, then `submit_turn()` per turn, then `shutdown()`. Read [`docs/spec/engine-api.md`](https://github.com/microsoft/amplifier-agent/blob/main/docs/spec/engine-api.md) before using it; it is the normative contract and names every public symbol. - ## HTTP face ```bash @@ -145,7 +245,7 @@ amplifier-agent run -y --session-id chat-42 --workspace my-app \ --output json --display ndjson "Hello, agent." ``` -Stdout carries a single JSON envelope (`protocolVersion`, `sessionId`, `turnId`, `reply`, `error`, `metadata`). Under the default `--output text` it is the reply text only. Diagnostics (tool calls, progress) go to **stderr** only, as one JSON-RPC notification per line under `--display ndjson`. +Stdout carries a single JSON envelope (`protocolVersion`, `sessionId`, `turnId`, `reply`, `error`, `metadata`). Under the default `--output text` it is the reply text only. Diagnostics (tool calls, progress) go to **stderr** only, as one JSON-RPC notification per line under `--display ndjson`. Large prompts can go in a file with `--prompt-file `, which is mutually exclusive with the positional prompt. Continuity is per `(workspace, session-id)`: @@ -157,17 +257,17 @@ amplifier-agent run -y --session-id chat-42 --fresh "Start over." `--resume` and `--fresh` are mutually exclusive. -## Rules that break integrations when ignored +## Rules that break subprocess integrations when ignored 1. **Declare an approval policy explicitly.** A headless host must pass `-y`, `-n`, or `approval.mode` in a host config. With none of those and no TTY, the run exits 2 with `approval_unconfigured` rather than silently doing nothing and exiting 0. -2. **Set `--workspace`.** Without it, sessions are scoped to the current working directory, so a host that spawns from varying directories sees its sessions fragment. Multi-tenant hosts always set it. +2. **Set `--workspace`.** Same fragmentation risk as embedding. 3. **Pin the protocol version and fail loudly.** Wrappers pass `--protocol-version`; a mismatch returns `protocol_version_mismatch` and exits non-zero instead of misbehaving quietly. 4. **Never parse stderr for results.** Streams are strictly separated. Parse stdout for the envelope, stderr for progress. -5. **One host config file per agent instance.** Pass `--config ` every turn. The top level is closed (`approval`, `provider`, `providers`, `mcp`, `skills`, `debug`, `allowProtocolSkew`); an unknown key is an error, not a warning. To debug what the engine actually sends and receives, set `debug.rawLlmPayloads: true` — full, credential-redacted provider payloads (uncapped) then land in each session's `context-intelligence/events.jsonl`; see [`docs/CONFIGURATION.md`](https://github.com/microsoft/amplifier-agent/blob/main/docs/CONFIGURATION.md). +5. **One host config file per agent instance.** Pass `--config ` every turn. The top level is closed (`approval`, `provider`, `providers`, `mcp`, `skills`, `debug`, `allowProtocolSkew`); an unknown key is an error, not a warning. To debug what the engine actually sends and receives, set `debug.rawLlmPayloads: true` and full, credential-redacted provider payloads (uncapped) land in each session's `context-intelligence/events.jsonl`; see [`docs/CONFIGURATION.md`](https://github.com/microsoft/amplifier-agent/blob/main/docs/CONFIGURATION.md). 6. **Record `metadata.engineVersion` and `metadata.bundleDigest`** from the envelope alongside your own logs, so a behavior change is attributable. 7. **Install the engine as the user that runs the host**, and verify with `amplifier-agent doctor` at deploy time. -A per-instance config file looks like this: +A per-instance config file looks like this. An embedder can load the same file with `amplifier_agent_lib.config.load_config` and pass it to `make_turn_handler(host_config=...)`: ```json { @@ -180,12 +280,18 @@ A per-instance config file looks like this: ## Error codes at the integration seams -[`docs/spec/envelope-and-errors.md`](https://github.com/microsoft/amplifier-agent/blob/main/docs/spec/envelope-and-errors.md) is the full registry, wire codes plus CLI-only codes. These are the ones raised at the seams a host owns: binary discovery, argv and config validation, the protocol handshake, approval, and session resume. The rest fire inside a turn. +[`docs/spec/envelope-and-errors.md`](https://github.com/microsoft/amplifier-agent/blob/main/docs/spec/envelope-and-errors.md) is the full registry, wire codes plus CLI-only codes. + +An embedder sees only the codes that fire inside a turn, plus `provider_not_configured`. Everything else in this table is a **subprocess seam**: binary discovery, argv and config validation, the protocol handshake, and process exit codes do not exist when you import the library. | Code | Raised when | What to do | |---|---|---| +| `provider_not_configured` | No provider credentials resolvable at boot | Set the provider's env var or run `amplifier-agent auth set`; confirm with `doctor`. Embedders: check `enumerate_resolvable_providers()` first | +| `approval_denied`, `approval_timeout` | A tool call was declined, or no answer arrived in time | Expected under `-n`, a prompt policy, or an `ApprovalSystem` that declines; classification `approval`, exit 3 | +| `session_not_found` | Resume names a session id with no transcript | Resume only after a first turn has persisted, or start fresh | +| `argv_mode_unknown` (exit 2), `unknown_mode` (HTTP 400) | `--mode`, or an `[amplifier-agent:mode=...]` directive, names a mode discovery did not find | Check `amplifier-agent modes list` or `GET /v1/modes` | +| `modes_unavailable` (exit 1, HTTP 503) | Mode discovery itself failed | An engine-side fault, not a bad mode name. Do not retry with a different name | | `binary_not_found` | The SDK resolves neither `AMPLIFIER_AGENT_BIN` nor `amplifier-agent` on `PATH` | Install the engine as the user that runs the host, or set `AMPLIFIER_AGENT_BIN` | -| `provider_not_configured` | No provider credentials resolvable at boot | Set the provider's env var or run `amplifier-agent auth set`; confirm with `doctor` | | `approval_unconfigured` | Non-interactive, with no policy at any tier | Pass `-y` or `-n`, or set `approval.mode` in the host config | | `argv_workspace_invalid` | `--workspace` fails the slug grammar `^[a-z0-9][a-z0-9-]{0,63}$` | Slugify tenant or project ids before passing them: lowercase, no leading `_`, 64 chars max | | `config_unreadable`, `config_malformed_json` | The `--config` file could not be opened, or is not a JSON object | Check the path the host wrote, and that it serialized an object | @@ -193,16 +299,14 @@ A per-instance config file looks like this: | `config_invalid_type` | A known key has the wrong type, or an unknown sub-key in a closed inner shape | `skills.*` and `debug.*` are closed and raise this rather than `config_unknown_key`, which is reserved for the top level and `providers.` entries | | `config_invalid_provider_module` | `provider.module` is not a known provider | One of `anthropic`, `openai`, `azure-openai`, `ollama`, `github-copilot`, `openai-chatgpt`, `chat-completions`, `gemini`, `vllm`. `"auto"` is not valid | | `protocol_version_mismatch` | Wrapper and engine protocol versions differ | Update the lagging side. `allowProtocolSkew` is an unblock, not a fix | -| `lifecycle_unsupported` | `submit()` called twice on one handle | New handle per turn, same `sessionId` with `resume` | +| `lifecycle_unsupported` | `submit()` called twice on one SDK handle | New handle per turn, same `sessionId` with `resume` | | `env_injection_rejected` | The wrapper refused the environment you asked it to inject | Check the key against the wrapper's allowlist and blocked-key list | -| `approval_not_supported_in_v1` | An interactive approval callback was passed to the SDK | Use `approval: { mode: 'yes' \| 'no' }`; there is no callback channel yet | -| `session_not_found` | Resume names a session id with no transcript | Resume only after a first turn has persisted, or start with `--fresh` | -| `argv_mode_unknown` (exit 2), `unknown_mode` (HTTP 400) | `--mode`, or an `[amplifier-agent:mode=...]` directive, names a mode discovery did not find | Check `amplifier-agent modes list` or `GET /v1/modes` | -| `modes_unavailable` (exit 1, HTTP 503) | Mode discovery itself failed | An engine-side fault, not a bad mode name. Do not retry with a different name | -| `approval_denied`, `approval_timeout` | A tool call was declined, or no answer arrived in time | Expected under `-n` or a prompt policy; classification `approval`, exit 3 | +| `approval_not_supported_in_v1` | An interactive approval callback was passed to the SDK | Use `approval: { mode: 'yes' \| 'no' }`; there is no callback channel over the wire. Embedders implement `ApprovalSystem` directly instead | -Exit codes: `0` success, `1` engine or transport, `2` protocol (skew, malformed argv, bad config), `3` approval runtime, `130` SIGINT. `2` and `3` are separable on purpose, so CI can gate on protocol failures and hosts can build deferral flows without parsing the envelope. When a parseable envelope is present it is authoritative; exit codes are informational. +Exit codes are a subprocess concept: `0` success, `1` engine or transport, `2` protocol (skew, malformed argv, bad config), `3` approval runtime, `130` SIGINT. `2` and `3` are separable on purpose, so CI can gate on protocol failures and hosts can build deferral flows without parsing the envelope. When a parseable envelope is present it is authoritative; exit codes are informational. ## Checklist for a new integration -Install the engine and confirm with `doctor`; pin the protocol version; declare an approval policy; set `--workspace`; pass `--output json --display ndjson` and parse both streams; write one config file per instance; log the engine version and bundle digest from every envelope. +**Embedding:** declare the dependency and confirm with `doctor`; pick a provider from `enumerate_resolvable_providers()`; clear `mount_plan["providers"]` before injecting; set `workspace`; one `Engine` per turn with `is_resumed=True` after the first; supply an `ApprovalSystem` matching your trust model; log the engine version and usage fields per turn. + +**Subprocess:** install the engine and confirm with `doctor`; pin the protocol version; declare an approval policy; set `--workspace`; pass `--output json --display ndjson` and parse both streams; write one config file per instance; log the engine version and bundle digest from every envelope.