# pagent — full documentation (English) > Auto-generated for LLM and coding agents. Do not edit by hand. > Regenerate: `cd docs && npm run build:llms` > Index: https://github.com/SyncLionPaw/pagent/blob/main/llms.txt > Site: https://synclionpaw.github.io/pagent/ --- # pagent — agent reference Dense API reference for **coding agents** and LLM tools. Human docs: . **Machine indexes:** [llms.txt](https://synclionpaw.github.io/pagent/llms.txt) (page list) · [llms-full.txt](https://synclionpaw.github.io/pagent/llms-full.txt) (single-file bundle) · [raw llms.txt](https://raw.githubusercontent.com/SyncLionPaw/pagent/main/llms.txt) · repo [AGENTS.md](https://github.com/SyncLionPaw/pagent/blob/main/AGENTS.md) ## What it is - **Python 3.11+** async library: `Agent` + `Session` + `@tool` functions over **OpenAI-compatible** `POST /v1/chat/completions`. - **Not included:** file edit, shell, MCP, RAG, parallel tools, checkpoints — you implement those in your app. - **Package:** `pip install pagent` · optional `pagent[search]`, `pagent[tokens]`, `pagent[ssh]`. ## Core types | Symbol | Role | |--------|------| | `Session(system_prompt)` | OpenAI-shaped message list; `session += {"role":"user","content":"..."}` | | `SlidingWindowSession` | Trim by **token** budget (not message count) | | `CompactingSession` | LLM summarization when context grows | | `LLM`, `DeepSeek`, `Ollama`, `Vllm`, `Sglang` | Provider clients; model name in constructor | | `Agent(llm, session, tools=None, max_turns=8)` | Runs the tool loop | | `RunEnd` | Final model step: `.content`, `.tool_calls`, `.reasoning_content`, `.usage` | | `@tool()` | Decorator; schema from type hints + docstring | ## Agent.run APIs | Method | Returns | When | |--------|---------|------| | `await agent.run(user_input, **run_kwargs)` | `RunEnd` | Blocking; no stream | | `agent.arun(user_input, **run_kwargs)` | `AsyncIterator[str]` | Answer text chunks only (`TextDelta`) | | `agent.arun_events(user_input, **run_kwargs)` | `AsyncIterator[Event]` | Full timeline for Python UI | | `agent.arun_wire(user_input, **run_kwargs)` | `AsyncIterator[str]` | NDJSON lines (JSON-RPC notifications) | `run_kwargs` are forwarded to the provider (e.g. `reasoning_effort="medium"` for DeepSeek). ## Event types (`arun_events`) All are frozen dataclasses; import from `pagent`. | Event | Fields (summary) | When | |-------|------------------|------| | `RunBegin` | `user_input` | Run starts | | `TurnBegin` | `turn` | One LLM call starts (0-based) | | `TextDelta` | `text` | Assistant content chunk | | `ReasoningDelta` | `text` | Reasoning chunk (provider-specific) | | `StepEnd` | `content`, `tool_calls`, `reasoning_content`, `usage` | One LLM step done | | `ToolCallBegin` | `tool_call_id`, `name`, `arguments` | Before tool execution | | `ToolResult` | `tool_call_id`, `name`, `content` | After tool; written to session | | `TurnEnd` | `turn`, `stopped` | Turn done; `stopped=True` if no more model calls this run | | `RunEnd` | same as `StepEnd` | Entire run finished | Typical sequence (tools): `RunBegin` → `TurnBegin` → `TextDelta*` → `StepEnd` → `ToolCallBegin` → `ToolResult` → `TurnEnd(stopped=False)` → next turn → … → `RunEnd`. ## Wire protocol (`arun_wire`) One **JSON-RPC 2.0 notification** per NDJSON line (no `id`): ```json {"jsonrpc":"2.0","method":"TextDelta","params":{"text":"hi"}} ``` `method` equals the Python event class name. Same ordering as `arun_events()`. Python helpers: `encode_event_line`, `decode_event_line`, `event_to_rpc`, `rpc_to_event`. Inbound control (cancel, tool approval, steer) is **not** in Wire — use your own HTTP/API. ## Minimal example ```python import asyncio from pagent import Agent, LLM, Session, tool @tool() def get_weather(city: str) -> str: """Return weather for the city.""" return f"Sunny in {city}." async def main(): agent = Agent( llm=LLM("gpt-4o-mini"), session=Session("You are helpful."), tools=[get_weather], max_turns=8, ) end = await agent.run("Weather in Xiamen?") print(end.content) asyncio.run(main()) ``` ## Streaming example ```python from pagent import TextDelta, ToolCallBegin, RunEnd async for event in agent.arun_events("Hello"): match event: case TextDelta(text=t): print(t, end="", flush=True) case ToolCallBegin(name=n): print(f"\n[tool {n}]") case RunEnd(content=c): print(f"\n[done]") ``` ## Environment variables | Provider | Variable | |----------|----------| | OpenAI | `OPENAI_API_KEY` | | DeepSeek | `DEEPSEEK_API_KEY` | | Ollama | optional `OLLAMA_API_KEY`; default `http://127.0.0.1:11434/v1` | ## Repo map ```text src/pagent/agent.py Agent loop src/pagent/session.py Session, SlidingWindowSession, CompactingSession src/pagent/llm.py LLM providers, RunEnd src/pagent/tool.py @tool, FunctionTool src/pagent/events.py Event dataclasses src/pagent/wire.py JSON-RPC encode/decode examples/wire_browser/ FastAPI + browser NDJSON consumer tests/ pytest ``` ## Extended docs (English) | Topic | Path | |-------|------| | Quick start | `docs/guide/quick-start.md` | | Providers | `docs/guide/providers.md` | | Prompt & session | `docs/guide/prompt.md` | | Tools | `docs/guide/tools.md` | | Built-in tools | `docs/guide/defaults.md` | | Memory helper | `docs/guide/memory.md` | | Events (full) | `docs/events.md` | | Wire (full) | `docs/wire.md` | | Reasoning streams | `docs/reasoning.md` | | Development | `docs/development.md` | Raw URLs: prefix `https://raw.githubusercontent.com/SyncLionPaw/pagent/main/`. --- # Quick start Prerequisites: [Install](./install) (Python 3.11+, pip / uv / conda). ## Minimal agent ```python import asyncio import os from pagent import Agent, LLM, Session, tool @tool() def get_weather(city: str) -> str: """Return weather for the city.""" return f"Sunny in {city} today." async def main(): if not os.getenv("OPENAI_API_KEY"): raise SystemExit("Set OPENAI_API_KEY first.") agent = Agent( llm=LLM("gpt-4o-mini"), session=Session("You are helpful. Use tools when needed."), tools=[get_weather], max_turns=8, ) result = await agent.run("What's the weather in Xiamen?") print(result.content) asyncio.run(main()) ``` `run()` returns **`RunEnd`** — use `.content` for the answer. ## Streaming APIs | API | Returns | Use when | |-----|---------|----------| | `agent.run()` | `RunEnd` | No streaming | | `agent.arun()` | `str` chunks | Typing effect, text only | | `agent.arun_events()` | `Event` objects | Python UI, tests | | `agent.arun_wire()` | NDJSON lines | Browser, VS Code plugin, any JSON consumer | Next: [Providers & API keys](./providers) · [Events](/events) · [Wire protocol](/wire) ## Examples (clone repo) ```bash git clone https://github.com/SyncLionPaw/pagent.git cd pagent export DEEPSEEK_API_KEY="your-key" # for DeepSeek examples uv run pagent uv run python -m examples.pagentv4.thread_based.conversation_only uv run python -m examples.pagentv4.thread_based.code_runner uv run --with fastapi --with uvicorn python examples/wire_browser/server.py ``` | Example | Description | |---------|-------------| | [`examples/README.md`](https://github.com/SyncLionPaw/pagent/blob/main/examples/README.md) | Classified examples index | | [`examples/pagentv4/thread_based`](https://github.com/SyncLionPaw/pagent/tree/main/examples/pagentv4/thread_based) | ChatRunner / CodeRunner examples | | [`examples/pagentv4/runner`](https://github.com/SyncLionPaw/pagent/tree/main/examples/pagentv4/runner) | Runner.create examples | | [`wire_browser`](https://github.com/SyncLionPaw/pagent/tree/main/examples/wire_browser) | FastAPI + browser UI | --- # Providers & API keys pagent talks to any server that implements **OpenAI Chat Completions** (`/v1/chat/completions`). ## Built-in classes | Class | Default model (examples) | Environment variable | |-------|------------------------|----------------------| | `LLM("gpt-4o-mini")` | as passed | `OPENAI_API_KEY` | | `DeepSeek("deepseek-v4-flash")` | as passed | `DEEPSEEK_API_KEY` | | `Ollama("llama3.2")` | as passed | optional `OLLAMA_API_KEY` | | `Vllm`, `Sglang` | as passed | provider-specific | ```python from pagent import DeepSeek, LLM, Ollama llm = LLM("gpt-4o-mini") llm = DeepSeek("deepseek-v4-flash") llm = Ollama("llama3.2") # http://127.0.0.1:11434/v1 ``` ## Reasoning models Some providers expose **`reasoning_content`** (e.g. DeepSeek). Use `reasoning_effort` in `run_kwargs` and handle `ReasoningDelta` when streaming. See [Reasoning streams](/reasoning). ## Optional extras ```bash pip install "pagent[search]" # web_search tool (ddgs) pip install "pagent[tokens]" # HuggingFace tokenizers for some models ``` --- # Prompt Use `Session` for the system prompt and chat history, then pass it to `Agent`. ## System prompt ```python from pagent import Session session = Session("You are a concise assistant.") ``` ## User message ```python session += {"role": "user", "content": "What's 2+2?"} ``` ## Run ```python from pagent import Agent, LLM agent = Agent(llm=LLM("gpt-4o-mini"), session=session, tools=[], max_turns=8) await agent.run("What's 2+2?") ``` Each `run()` appends the user turn and the reply to `session`. ## Long chats Too many tokens? Use `SlidingWindowSession` to drop old turns, or `CompactingSession` and `await session.compact()` to summarize. ```python from pagent import SlidingWindowSession, CompactingSession, LLM session = SlidingWindowSession("You are helpful.", max_tokens=8000) llm = LLM("gpt-4o-mini") session = CompactingSession("You are helpful.", llm=llm, compact_at_tokens=6000) if session.should_compact: await session.compact() ``` ## Save ```python session.save_to_file("chat.json") ``` ## See also - [Tools](./tools) · [Memory](./memory) · [Quick start](./quick-start) --- # Tools Write a Python function, add `@tool()`, pass it in `tools=[...]`. ## Define a tool Docstring = what the model sees. Type hints = argument schema. ```python from pagent import tool @tool() def get_weather(city: str) -> str: """Return weather for a city.""" return f"Sunny in {city} today." ``` ## Use with Agent ```python from pagent import Agent, LLM, Session agent = Agent( llm=LLM("gpt-4o-mini"), session=Session("Use get_weather for weather."), tools=[get_weather], max_turns=8, ) await agent.run("Weather in Xiamen?") ``` Multiple tools: `tools=[a, b]`. Custom name: `@tool(name="weather", description="...")`. Built-in: [defaults](./defaults) (`clock`, `region`, `readfile`, `web_search`, `bash`). ## See also - [Prompt](./prompt) · [Memory](./memory) · [Defaults](./defaults) · [Events](/events) --- # Built-in tools Optional tools in `pagent.defaults`: `clock`, `region`, `readfile`, `web_search`, `bash`. ```python from pagent import Agent, LLM, Session, DEFAULT_TOOLS, bash, clock, readfile, region, web_search agent = Agent( llm=LLM("gpt-4o-mini"), session=Session("You are helpful."), tools=[*DEFAULT_TOOLS], # clock + region max_turns=8, ) ``` `DEFAULT_TOOLS` is `[clock, region]`. Add `web_search` yourself (needs extra install). ## clock {#clock} Current time as ISO 8601. ```python tools=[clock] # utc=True (default) or utc=False for local time ``` ## region {#region} OS locale and timezone hint (no GPS). ```python tools=[region] ``` ## readfile {#readfile} Read a UTF-8 text file under process `cwd` (absolute or relative path; `~` expanded). Up to **500 code points** per call (`max_chars`). Use **`offset`** to read the next window when the header says `continues at offset N`. ```python tools=[readfile] # readfile("src/pagent/defaults.py", max_chars=500, offset=0) # readfile("src/pagent/defaults.py", max_chars=500, offset=500) ``` ## bash {#bash} Run a **whitelisted** command in process `cwd` (no shell). Currently only **`ls`** is allowed; path arguments must resolve under the workspace (same rules as `readfile`). ```python tools=[bash] # bash("ls") # bash("ls -la src") ``` ## web_search {#web-search} Web search via DuckDuckGo. ```bash pip install 'pagent[search]' ``` ```python tools=[web_search] ``` ## See also - [Tools](./tools) (write your own `@tool`) --- # Memory Not chat history — a simple note list you paste into the prompt yourself (`pagent.memory`, experimental). ```python from pagent.memory import Memory from pagent import Session notes = Memory() notes.add("User prefers metric units.") session = Session(f"You are helpful.\n\nNotes:\n{notes.as_text()}") ``` Not wired into `Agent` automatically. Save/load: `notes.save_to_file("notes.json")`, `Memory.load_from_file(...)`. ## See also - [Prompt](./prompt) · [Tools](./tools) --- # Agent events For developers building custom UIs. New users: [Quick start](./guide/quick-start); internals: [development.md](./development.md). Structured events for observing the **Agent loop** without coupling to a specific UI (terminal, web, IDE). Inspired by the *Soul / Wire* split in products like Kimi Code: the loop **emits** facts; consumers **subscribe** and render. **Status:** `Agent.arun_events()` emits these events. `Agent.arun()` is a thin wrapper that yields only `TextDelta.text` for backward compatibility. ## Native Event vs Wire (JSON-RPC) **Same timeline, two shapes** — not two different event systems. | Layer | What you get | Typical use | |-------|----------------|-------------| | **Native `Event`** | Frozen Python dataclasses (`TextDelta`, `RunEnd`, …) | Python services, CLI, tests, notebooks — `match` / `isinstance`, IDE types | | **Wire (JSON-RPC)** | One NDJSON line per event: `{"jsonrpc":"2.0","method":"TextDelta","params":{...}}` | Browser/mobile, other languages, SSE/WebSocket, log files (`wire.jsonl`) | | API | Returns | |-----|---------| | `agent.run()` | Final `RunEnd` only (blocking, no stream) | | `agent.arun()` | `str` text chunks only | | `agent.arun_events()` | **`Event`** objects (full stream) | | `agent.arun_wire()` | **`str`** NDJSON lines (same stream, serialized) | Wire is a thin serializer over `arun_events()`; see [wire.md](./wire.md). In Python you can also `encode_event_line(event)` when bridging to a socket yourself. **Choose native** when the consumer is Python and you want type checking and pattern matching. **Choose wire** when the consumer is not Python, or you need a stable on-the-wire format for HTTP/SSE/WebSocket. ## Import ```python from pagent import ( Event, RunBegin, TurnBegin, TextDelta, ReasoningDelta, StepEnd, ToolCallBegin, ToolResult, TurnEnd, RunEnd, ) ``` `Event` is a union of all concrete event classes (for `match` / `isinstance`). ## Event reference ### Lifecycle | Event | Fields | When (intended) | |-------|--------|-----------------| | `RunBegin` | `user_input: str` | User message appended to `session`; loop starts | | `TurnBegin` | `turn: int` | Start of one LLM call inside `max_turns` (0-based) | | `TurnEnd` | `turn: int`, `stopped: bool` | Assistant message written; `stopped=True` if loop will not call the model again this run | | `RunEnd` | `content`, `tool_calls`, `reasoning_content`, `usage` | Entire `run` / `arun` finished (same type as `LLM.invoke` return) | ### Streaming | Event | Fields | When (intended) | |-------|--------|-----------------| | `TextDelta` | `text: str` | Chunk of assistant `content` from `invoke_stream` | | `ReasoningDelta` | `text: str` | Chunk of `reasoning_content` (provider-specific) | ### Step boundary | Event | Fields | When (intended) | |-------|--------|-----------------| | `StepEnd` | `content`, `tool_calls`, `reasoning_content`, `usage` | One LLM step finished (stream assembled or single `invoke`) | Same fields as `RunEnd` for that step. `tool_calls` uses the OpenAI shape: `[{ "id", "type", "function": { "name", "arguments" } }, ...]`. ### Tools | Event | Fields | When (intended) | |-------|--------|-----------------| | `ToolCallBegin` | `tool_call_id`, `name`, `arguments` | About to execute one tool | | `ToolResult` | `tool_call_id`, `name`, `content` | Tool output appended to `session` as `role: tool` | ## Typical sequences ### Single turn, text only ```text RunBegin TurnBegin(0) TextDelta … StepEnd(content=…, tool_calls=[]) TurnEnd(0, stopped=True) RunEnd ``` ### One turn with tools, then final answer ```text RunBegin TurnBegin(0) TextDelta … StepEnd(…, tool_calls=[…]) ToolCallBegin(…) ToolResult(…) TurnEnd(0, stopped=False) TurnBegin(1) TextDelta … StepEnd(…, tool_calls=[]) TurnEnd(1, stopped=True) RunEnd ``` ### Hit `max_turns` with tools still pending Last `TurnEnd` may have `stopped=False`; the final `RunEnd` event reflects the last assistant message (may still include `tool_calls`). ```mermaid sequenceDiagram participant UI participant Agent participant LLM participant Tools UI->>Agent: user_input Agent-->>UI: RunBegin loop each turn Agent-->>UI: TurnBegin Agent->>LLM: invoke_stream LLM-->>Agent: chunks Agent-->>UI: TextDelta Agent-->>UI: StepEnd opt tool_calls Agent-->>UI: ToolCallBegin Agent->>Tools: call Agent-->>UI: ToolResult end Agent-->>UI: TurnEnd end Agent-->>UI: RunEnd ``` ## Consumer example ```python async for event in agent.arun_events("Hello"): match event: case TextDelta(text=t): print(t, end="", flush=True) case ToolCallBegin(name=n): print(f"\n[tool {n}]") case RunEnd(content=c): print(f"\n[done: {c!r}]") ``` Keep `arun()` for callers that only need text: ```python async for chunk in agent.arun("Hello"): print(chunk, end="") # str, not Event ``` ## Design notes - **Frozen dataclasses** — events are immutable snapshots; safe to queue or log. - **Wire protocol** — JSON-RPC 2.0 notifications + NDJSON: [wire.md](./wire.md). Use `Agent.arun_wire()` or `encode_event_line()`. - **Inbound control** (approval, external tools, `steer`) — not modeled; would be separate request types, not `Event`. - **Session vs events** — `session.messages` remains the LLM API history; events are a parallel UI timeline (compare Kimi `context.jsonl` vs `wire.jsonl`). ## Source Definitions: [`src/pagent/events.py`](https://github.com/SyncLionPaw/pagent/blob/main/src/pagent/events.py) **reasoning_content examples** (run vs stream, `--zh` 鸡兔同笼): [reasoning.md](./reasoning.md) --- # Wire protocol (JSON-RPC 2.0) For **web / mobile frontends** and any transport that speaks JSON lines (HTTP chunked, SSE `data:` payloads, WebSocket text frames). This is **not** a second event system. `arun_wire()` serializes the same stream as `arun_events()`; semantics and ordering match [events.md](./events.md). ## How Wire fits (diagrams) ### Stack: one timeline, two layers ```mermaid flowchart LR A[Agent arun_events] W[Wire NDJSON] T[HTTP SSE WS] U[Client UI] A --> W --> T --> U ``` Inbound control (cancel, tool approval, steer) is **not** on this arrow — use your own HTTP/API beside the stream. ### One Python event → one line ```mermaid flowchart LR E[TextDelta] R[JSON-RPC] L[NDJSON line] E --> R --> L ``` ### Typical stream (single turn, text only) ```mermaid sequenceDiagram participant App as Server participant Agent participant Client App->>Agent: arun_wire Agent-->>Client: RunBegin Agent-->>Client: TurnBegin loop stream Agent-->>Client: TextDelta end Agent-->>Client: StepEnd Agent-->>Client: TurnEnd Agent-->>Client: RunEnd ``` Client: parse each NDJSON line; append `TextDelta` to the answer pane. ### With tools (two turns) ```mermaid sequenceDiagram participant Client as Client UI participant Agent as Agent Agent-->>Client: RunBegin Agent-->>Client: TurnBegin turn=0 Agent-->>Client: TextDelta Agent-->>Client: StepEnd tool_calls set Agent-->>Client: ToolCallBegin Agent-->>Client: ToolResult Agent-->>Client: TurnEnd stopped=false Agent-->>Client: TurnBegin turn=1 Agent-->>Client: TextDelta Agent-->>Client: StepEnd Agent-->>Client: TurnEnd stopped=true Agent-->>Client: RunEnd ``` Full event list and ordering: [events.md](./events.md). ### When to use Wire vs native Event | Use **Wire** (`arun_wire`, NDJSON) | Use **native Event** (`arun_events`) | |-------------------------------------|--------------------------------------| | TypeScript / Swift / Kotlin client | Python CLI, FastAPI handler, tests | | SSE or WebSocket to the browser | `match event:` / `isinstance` in-process | | Persist or replay `wire.jsonl` | Rich objects (e.g. OpenAI `usage` before encode) | | Use **`arun()`** only when you need printed answer text and nothing else (e.g. simple scripts). Python backends that talk to a browser often: `arun_events()` in the server loop, `encode_event_line(event)` per chunk to the socket — or `arun_wire()` directly if you only forward lines. Python events from `Agent.arun_events()` map 1:1 to **JSON-RPC 2.0 notifications** (no `id` — they are pushed, not request/response pairs). ## Message shape ```json { "jsonrpc": "2.0", "method": "TextDelta", "params": { "text": "Hello" } } ``` | Field | Value | |-------|--------| | `jsonrpc` | Always `"2.0"` | | `method` | Event class name: `RunBegin`, `TextDelta`, `ToolCallBegin`, `RunEnd`, … | | `params` | Dataclass fields as a JSON object (see [events.md](./events.md)) | There is **no** `id` field. Inbound control (approve tool, cancel) is out of scope; use your own API for that. ## NDJSON stream One notification per line (newline-delimited JSON): ```text {"jsonrpc":"2.0","method":"RunBegin","params":{"user_input":"Hi"}} {"jsonrpc":"2.0","method":"TextDelta","params":{"text":"4"}} {"jsonrpc":"2.0","method":"RunEnd","params":{"content":"4","tool_calls":[],"reasoning_content":"","usage":null}} ``` ## Python ```python from pagent import Agent, LLM, Session, encode_event_line, decode_event_line async for line in agent.arun_wire("2+2?"): # line is already NDJSON (ends with \n) send_to_websocket(line) # Or encode/decode manually: from pagent import event_to_rpc, rpc_to_event msg = event_to_rpc(TextDelta("x")) event = rpc_to_event(msg) ``` Exports: `event_to_rpc`, `rpc_to_event`, `encode_event`, `decode_event`, `encode_event_line`, `decode_event_line`, `JSONRPC_VERSION`. ## TypeScript consumer (sketch) ```typescript type WireMsg = { jsonrpc: "2.0"; method: string; params: Record }; function onLine(line: string) { const msg: WireMsg = JSON.parse(line); switch (msg.method) { case "TextDelta": appendAnswer(String(msg.params.text ?? "")); break; case "ReasoningDelta": appendThinking(String(msg.params.text ?? "")); break; case "ToolCallBegin": showTool(msg.params.name as string, msg.params.arguments as string); break; case "RunEnd": finish(msg.params.content as string); break; } } ``` ## `usage` in `StepEnd` / `RunEnd` When present, `params.usage` is a plain object: ```json { "prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15 } ``` ## Methods reference Same semantics as [events.md](./events.md); `method` equals the Python event class name. | `method` | `params` keys | |----------|----------------| | `RunBegin` | `user_input` | | `TurnBegin` | `turn` | | `TurnEnd` | `turn`, `stopped` | | `TextDelta` | `text` | | `ReasoningDelta` | `text` | | `StepEnd` | `content`, `tool_calls`, `reasoning_content`, `usage` | | `ToolCallBegin` | `tool_call_id`, `name`, `arguments` | | `ToolResult` | `tool_call_id`, `name`, `content` | | `RunEnd` | `content`, `tool_calls`, `reasoning_content`, `usage` | ## Runnable demo [`examples/wire_browser/`](https://github.com/SyncLionPaw/pagent/tree/main/examples/wire_browser) — FastAPI server + single-page UI. See [Wire demo](./wire-demo) on this site. ## Source [`src/pagent/wire.py`](https://github.com/SyncLionPaw/pagent/blob/main/src/pagent/wire.py) --- # reasoning_content examples Models such as DeepSeek may return **`reasoning_content`** (chain-of-thought) alongside **`content`** (user-facing answer). pagent carries both on **`RunEnd`**; in streaming mode **`ReasoningDelta`** and **`TextDelta`** events deliver them separately. Event reference: [events.md](./events.md) ## Usage The examples below show the direct API shape. Runnable examples are grouped in [`examples/README.md`](https://github.com/SyncLionPaw/pagent/blob/main/examples/README.md). ## Non-streaming: RunEnd ```python end = await agent.run(question, reasoning_effort="medium") print(end.reasoning_content) print(end.content) ``` Use **`end.content`**, not `print(end)`, to avoid dumping the full `RunEnd` repr. ## Streaming: arun_events ```python answer_started = False async for event in agent.arun_events(question, reasoning_effort="medium"): match event: case ReasoningDelta(text=t): print(t, end="", flush=True) case TextDelta(text=t): if not answer_started: print("\nanswer: ", end="", flush=True) answer_started = True print(t, end="", flush=True) case RunEnd(): print() ``` `agent.arun()` still yields answer text only (filters `TextDelta` internally). ## Questions - **English (default):** three mislabeled boxes puzzle. - **Chinese (`--zh`):** 鸡兔同笼 — 35 heads, 94 legs (answer: 23 chickens, 12 rabbits). Edit `QUESTION_EN` / `QUESTION_ZH` in `reasoning_common.py`. ## reasoning_effort Pass through `run_kwargs`, e.g. `reasoning_effort="medium"`. Whether `reasoning_content` appears depends on the provider and model. --- # Wire demo (local browser UI) Full-stack example: **FastAPI** serves a chat UI; the browser consumes **`Agent.arun_wire()`** as `application/x-ndjson`. ::: tip GitHub Pages cannot host the live demo The docs site is static only. Run the server locally to try streaming chat. ::: ## Preview ![pagent wire demo — chat UI with reasoning and Wire log](/wire-demo.png) Streaming reply, optional **reasoning** block, and **Wire log** drawer (JSON-RPC lines from `arun_wire()`). ## Architecture ### Components #### Browser and server ```mermaid flowchart TB B[Browser] S[FastAPI] B -->|POST /api/chat| S S -->|NDJSON stream| B ``` #### Inside FastAPI `Agent.arun_wire`, tools, session **小帕**: ```mermaid flowchart LR A[Agent] L[DeepSeek] A <-->|chat API| L ``` `GET /` serves `index.html`. The browser parses each Wire line (`method` + `params`) for the UI and drawer. | Piece | File | Role | |-------|------|------| | SPA | `static/index.html` | `fetch("/api/chat")`, read NDJSON lines, render bubbles / tools / reasoning | | API | `server.py` | `StreamingResponse` from `agent.arun_wire(message)` | | Library | `pagent` | Agent loop, events → JSON-RPC Wire ([protocol](./wire)) | Each chat request creates a **new** `Agent` (demo simplicity; a real app would reuse session per user). ### Request flow ```mermaid sequenceDiagram participant U as User participant UI participant API participant A as Agent participant LLM U->>UI: send UI->>API: POST /api/chat API->>A: arun_wire A->>LLM: completions LLM-->>A: deltas A-->>UI: Wire events UI->>U: bubble + drawer ``` Turns, `TextDelta` / `ToolResult`, and `RunEnd` are more events on the same `A-->>UI` arrow (see [events](./events)). **Stop** uses `AbortController` on fetch — not a Wire `method`. ### Cancel / stop ```mermaid flowchart LR S[Stop] --> A[AbortController] A --> H[HTTP closed] H --> E[Stream ends] ``` Wire has **no** cancel `method` — stopping generation closes the HTTP stream. Tool approval is also out of scope in this demo. ## Run Examples use `uv run`. New to **uv**? See the [official docs](https://docs.astral.sh/uv/). ```bash git clone https://github.com/SyncLionPaw/pagent.git cd pagent export DEEPSEEK_API_KEY="your-key" uv run --with fastapi --with uvicorn python examples/wire_browser/server.py ``` Open **http://127.0.0.1:8765** ## Stop - **Server:** `Ctrl+C` in the terminal - **While streaming:** click **停止** in the UI (aborts the HTTP request) ## What it shows - Chat UI with tool cards and optional reasoning block - Side drawer with raw Wire NDJSON lines - Same protocol as [Wire protocol](./wire) — not a separate message system Source: [examples/wire_browser/](https://github.com/SyncLionPaw/pagent/tree/main/examples/wire_browser) on GitHub. --- # Developer guide For contributors and anyone hacking the library. End users should start at the [documentation home](/) or [Quick start](./guide/quick-start). ## Layout ```text src/pagent/ v1 library src/pagentv4/ v4 library (core, runtime, sandbox, skills) src/app/ application layer (REPL, CLI) on top of pagentv4 examples/ runnable demos grouped by category (see examples/pagentv4/) tests/ pytest docs/ documentation ``` Core: `agent.py`, `session.py`, `llm.py`, `tool.py`, `tokens.py`, `events.py`. ## Capability map | Module | Notes | |--------|--------| | `Session` | OpenAI-shaped messages; `SlidingWindowSession` trims by tokens; `CompactingSession` LLM-compresses history | | `LLM` | `invoke` / `invoke_stream`; returns `RunEnd` | | `Agent` | `run` / `arun` / `arun_events` / `arun_wire` | | `tokens` | `count_tokens`, `count_tokens_detail`, `format_context` | | `events` / `wire` | UI timeline — [events.md](./events.md), [wire.md](./wire.md) | 中文完整表:[开发指南](/zh/development) ## Out of scope Parallel tools, RAG, MCP, built-in file/shell tools, multimodal, checkpoints — build in your app. **Planned:** [Hooks support plan](./plans/hooks.md) (lifecycle hooks for tool approval, cancel, context injection; distinct from Event/Wire). ## Local development Uses [uv](https://docs.astral.sh/uv/) for env management. New to uv? See the [official docs](https://docs.astral.sh/uv/). ```bash uv sync --group dev --extra search pip install -e ".[search]" pre-commit install pytest -q ``` ## Documentation site Built with [VitePress](https://vitepress.dev/). Config: `docs/.vitepress/config.mts`, content: `docs/*.md`. Mermaid diagrams use [vitepress-plugin-mermaid](https://github.com/emersonbottero/vitepress-plugin-mermaid) (fenced ` ```mermaid ` blocks). **For coding agents / LLMs:** [agent-reference](./agent-reference), repo [AGENTS.md](https://github.com/SyncLionPaw/pagent/blob/main/AGENTS.md), [llms.txt](https://github.com/SyncLionPaw/pagent/blob/main/llms.txt), [llms-full.txt](https://github.com/SyncLionPaw/pagent/blob/main/llms-full.txt) (`npm run build:llms` in `docs/` regenerates the bundle). ```bash cd docs npm install npm run dev # http://localhost:5173/pagent/ npm run build # output in docs/.vitepress/dist/ ``` Node tooling lives under `docs/` (`package.json`, `package-lock.json`) so the repo root stays Python-only. Do **not** commit `docs/.vitepress/dist/` or `site/` — they are in `.gitignore`. Only Markdown sources under `docs/` live on `main`. On push to `main`, [docs.yml](https://github.com/SyncLionPaw/pagent/blob/main/.github/workflows/docs.yml) runs `npm run build` in `docs/` and publishes `docs/.vitepress/dist/` to the **`gh-pages`** branch. Enable in repo **Settings → Pages → Deploy from branch → gh-pages / root**. ## Publishing `.github/workflows/publish.yml` — PyPI via Trusted Publishing on release. ## See also - [events.md](./events.md) - [reasoning.md](./reasoning.md) --- # pagentv4 `pagentv4` is the newer typed API in this repository. Use these pages if you want: - `Provider` instead of `LLM` - `Message` / `Messages` instead of `Session` - `Runner` for thread-based orchestration, persistence, and sandbox workspaces - A **sandbox** (companion computer) with file and command tools - Optional **Thread** and **Skill** support for long-lived REPL-style apps ## Module layout ```text core/ AgentCore, Message, Provider, Tool, Event ithread/ IThread Protocol + ThreadSpec runtime/ loop_core, LoopAdapter, BaseRunner, Runner, VanillaRunner, Thread conversation/ ConversationStore implementations used through Thread sandbox/ Backend, Sandbox, built-in file/command tools tools/ reusable tool functions adapters/ ACP encode/decode skills/ SKILL.md discovery and on-demand loading ``` There are several layers inside `runtime/`: - `loop_core` defines the shared run / turn / tool loop semantics - `LoopAdapter` carries the shared loop skeleton, and each runner layers on capabilities: - `VanillaRunner(LoopAdapter)` — minimal in-memory environment - `BaseRunner(LoopAdapter)` — adds thread, conversation store, sandbox, skills - `Runner(BaseRunner)` — adds inbound control (steer/cancel/permit) and tool hooks ## Pages - [Quick start](./quick-start) - [Core types](./core-types) - [Messages](./messages) - [Tools](./tools) - [Events](./events) - [Sandbox](./sandbox) ## Status New work should use `pagentv4` and `app` (terminal REPL). The top-level `pagent` package still documents the older `Session + LLM` API for now. --- # pagentv4 Quick Start `pagentv4` is the message-centric API with a `Runner` orchestration layer. It replaces `Session` / `LLM` with `Message` / `Provider`, and adds optional sandbox and persistence support. Prerequisites: [Install](../guide/install) (Python 3.11+, pip / uv / conda). ## Open a thread `Runner` is bound to a **thread** for its entire lifetime: sandbox, messages, and agent are created together and torn down with `runner.close()`. ```python import asyncio import os from pagentv4 import DeepSeek, Runner async def main(): if not os.getenv("DEEPSEEK_API_KEY"): raise SystemExit("Set DEEPSEEK_API_KEY first.") runner = await Runner.create( "demo", DeepSeek("deepseek-v4-flash"), overrides={"backend": "local"}, extra_system="You are helpful and concise.", ) try: async for text in runner.run( "Explain tail recursion in one sentence.", return_type="text" ): print(text, end="", flush=True) print() finally: await runner.close() asyncio.run(main()) ``` ## Multi-turn on the same thread Call `runner.run()` again — messages accumulate and persist in the conversation store configured by the thread. With the default JSONL backend, the path is `/.pagent/threads//messages.jsonl`. ```python runner = await Runner.create("demo", provider, overrides={"backend": "local"}) try: async for text in runner.run("My name is Ada.", return_type="text"): print(text, end="") async for text in runner.run("What is my name?", return_type="text"): print(text, end="") finally: await runner.close() ``` Re-open the same `thread_id` later to resume. ## Sandbox + tools `Runner.create()` creates the sandbox from the thread spec, binds built-in file and command tools, and merges any extra tools you pass: ```python runner = await Runner.create( "demo", DeepSeek("deepseek-v4-flash"), overrides={"backend": "local"}, extra_system="Use tools when needed.", ) try: async for event in runner.run( "Create hello.txt under /home/agent with one greeting line." ): ... finally: await runner.close() ``` See [Sandbox](./sandbox) for backends (`local`, `docker`, `podman`, `ssh`). ## Streaming modes `runner.run()` defaults to `return_type="event"`. | API | Returns | Use when | |-----|---------|----------| | `runner.run(..., return_type="event")` | `Event` objects | Full timeline, Python UI | | `runner.run(..., return_type="text")` | `str` chunks | Answer text only | | `runner.run(..., return_type="message")` | `Message` objects | Observe assistant/tool messages | | `runner.run(..., return_type="acp")` | NDJSON lines | Socket / ACP / JSON consumers | ## Built-in providers ```python from pagentv4 import DeepSeek, Kimi, LongCat, MiMo, Ollama, Provider, Sglang, Vllm deepseek = DeepSeek("deepseek-v4-flash") ollama = Ollama("qwen3:8b") vllm = Vllm("my-model") sglang = Sglang("my-model") ``` `Provider` and the built-in subclasses forward to OpenAI-compatible `/v1/chat/completions`. ## Next - [Core types](./core-types) - [Messages](./messages) - [Tools](./tools) - [Events](./events) - [Sandbox](./sandbox) --- # pagentv4 Core Types `pagentv4` separates **configuration** (`AgentCore`), **conversation state** (`Messages`), **orchestration** (`Runner`), and optional **execution** (`Sandbox`). ## Main symbols | Symbol | Role | |--------|------| | `Provider`, `DeepSeek`, `Ollama`, `Vllm`, `Sglang`, … | OpenAI-compatible streaming clients | | `AgentCore(provider, system=None, tools=None, max_turns=8)` | Model + tool configuration | | `Runner` | Thread-bound orchestrator: sandbox + messages + tool loop | | `VanillaRunner(agent, messages=None)` | Lightweight in-memory loop with no thread, sandbox, or persistence | | `ChatAgent`, `CodeAgent`, `ThreadAgent`, `VanillaAgent` | Agent-style aliases for the matching runner classes | | `loop_core.run_event_loop(adapter, …)` | Shared event-loop kernel used by both runners | | `Message` | One typed message item with `role` + `content` | | `Messages` | In-memory message list with `to_openai()` conversion | | `ConversationStore`, `JsonlConversationStore`, `SqliteConversationStore` | Persist messages by id | | `Thread`, `ThreadSpec` | Long-lived thread: spec + messages + workspace | | `Sandbox` | Companion computer with files and commands | | `TurnResult` | One model turn summary: `content`, `tool_calls`, `reasoning_content` | | `Event` | Union of event dataclasses emitted by the run loop | ## What a `turn` means In `pagentv4`, a `turn` is one internal work cycle of the agent, not one user-facing conversation round. It includes: - one model generation - the tool execution requested by that round - the decision to continue or stop Because of that, one run may contain multiple turns. The user sends one `user_input`, while the agent may still go through turn 0, turn 1, turn 2, and so on until the run finishes. Do not treat `TurnResult` and `TurnEnd` as the same thing: - `TurnResult` is the summary of the model output for the current turn - `TurnEnd` is the event that actually marks the end of that turn ## AgentCore `AgentCore` is a configuration container. It does not own conversation history or run the tool loop by itself. ```python from pagentv4 import AgentCore, DeepSeek, tool @tool() def get_weather(city: str) -> str: """Return weather for a city.""" return f"Sunny in {city} today." agent = AgentCore( DeepSeek("deepseek-v4-flash"), system="You are concise.", tools=[get_weather], max_turns=8, ) ``` Notes: - `system=` is inserted by `Runner` if no system message is present. - Duplicate tool names are rejected at construction time. - `max_turns` must be `>= 1`. - `agent.generate_messages(messages)` performs one provider call only. - `Agent` remains as a compatibility alias for `AgentCore`. Some users prefer naming the whole runnable object an agent. `pagentv4` supports that style with aliases: ```python from pagentv4 import CodeAgent, ChatAgent, ThreadAgent # Same classes as CodeRunner, ChatRunner, and Runner. code = CodeAgent(agent, backend="local") chat = ChatAgent(agent, thread_id="demo") full = await ThreadAgent.create("demo", provider, overrides={"backend": "local"}) ``` ## Runner `Runner` is created only via `Runner.create()` and lives as long as its thread. It owns the multi-turn tool loop, sandbox, messages, and persistence. The layering looks like this: - `loop_core` handles the shared run, turn, tool call, `TurnResult`, `TurnEnd`, and `RunEnd` semantics - `LoopAdapter` carries the shared loop skeleton (`execute_tool`, `stream_agent_events`, `emit`, `run`) - `VanillaRunner(LoopAdapter)` reuses that loop with a minimal in-memory environment - `BaseRunner(LoopAdapter)` adds thread, conversation store, sandbox, and skills, and flushes after each turn - `Runner(BaseRunner)` adds the inbound control plane (steer/cancel/permit) and tool hooks This keeps the different runtime layers aligned on turn/tool behavior while still exposing different runtime capabilities. | Method | Role | |--------|------| | `Runner.create(thread_id, provider, …)` | Open thread → sandbox → agent | | `runner.run(user_input, …)` | One user turn with `return_type` projection | | `runner.close()` | Close sandbox | ```python from pagentv4 import DeepSeek, Runner runner = await Runner.create( "demo", DeepSeek("deepseek-v4-flash"), overrides={"backend": "local"}, extra_system="You are helpful.", tools=[my_tool], # optional extras merged with sandbox tools ) try: async for event in runner.run("hi"): ... finally: await runner.close() ``` Messages are flushed at each `TurnEnd` into the conversation store configured by the thread. With the default JSONL backend, the path is `/.pagent/threads//messages.jsonl`. ## loop_core `loop_core` is the shared runtime kernel used by both `Runner` and `VanillaRunner`. It handles the common steps in a run: - emit `RunBegin` - enter each `TurnBegin` - call `agent.generate_messages(messages)` - build `TurnResult` from the message slice - execute tool calls for the turn - emit `TurnEnd` and `RunEnd` `loop_core` leaves these concerns to the outer runner adapter: - thread lifecycle - sandbox - message persistence - inbound cancel / steer / checkpoint - tool hooks - skills injection ## Thread A **thread** binds conversation history, thread config, and workspace on disk: ```text /.pagent/threads// thread.toml workspace/ ``` Use `Thread.open(thread_id, overrides={...})` to create or resume a thread. The terminal app (`uv run pagent`) and `examples/app/repl.py` show the full pattern. ## Provider ```python from pagentv4 import Provider provider = Provider( "gpt-4o-mini", base_url=None, apikey=None, request_kwargs=None, ) ``` Reserved keys in `Provider.complete()`: - `model` - `messages` - `stream` - `tools` | Provider | Environment variable | |----------|---------------------| | `Provider` | `OPENAI_API_KEY` | | `DeepSeek` | `DEEPSEEK_API_KEY` | | `Kimi` | `MOONSHOT_API_KEY` | | `MiMo` | `MIMO_API_KEY` | | `LongCat` | `LONGCAT_API_KEY` | | `Ollama` | `OLLAMA_API_KEY` | | `Vllm` | `VLLM_API_KEY` | | `Sglang` | `SGLANG_API_KEY` | `ProviderProtocol` is the structural type for `complete()` (tests and custom backends may implement it without subclassing `Provider`). ## `run()` return types Valid `return_type` values: - `"event"`: raw events - `"text"`: `TextDelta.text` only - `"message"`: `Message` projection over the event stream - `"acp"`: NDJSON JSON-RPC notifications ## Differences from `pagent` - no `Session` - no `LLM` - typed `Message` objects instead of raw OpenAI-shaped dicts - `Runner` instead of `Agent.arun()` for the full loop - optional sandbox and conversation persistence If you want the older `Session + LLM + arun_events()` API, stay on the main `pagent` docs. --- # pagentv4 Messages `pagentv4` stores conversation state as typed `Message` objects rather than raw OpenAI-shaped dicts. ## Roles and content types | Role | Allowed content | |------|-----------------| | `system` | `TextChunk` | | `user` | `TextChunk`, `ImageUrl`, `AudioUrl` | | `assistant` | `TextChunk`, `ThinkingChunk`, `ToolCall` | | `tool` | `ToolResult` | The role/content pairing is validated in `Message`. ## Constructors ```python from pagentv4 import Message system = Message.system("You are helpful.") user = Message.user("Describe this image.") image = Message.user_image("https://example.com/cat.png") tool = Message.tool_result("call_1", "ok") ``` Assistant messages are often created from streamed events: ```python Message.assistant({"type": "text", "text": "hello"}) Message.assistant({"type": "thinking", "text": "let me think"}) ``` ## `Messages` `Messages` is a thin wrapper around `list[Message]`: ```python from pagentv4 import Message, Messages msgs = Messages() msgs += Message.system("You are concise.") msgs += Message.user("Hello") ``` Useful methods: - `len(msgs)` - iteration over `Message` - `msgs.to_openai()` to export provider payloads - `msgs.save_to_jsonl(path)` / `Messages.load_from_jsonl(path)` ## Conversion to provider payloads `Messages.to_openai()` performs a few important merges: - consecutive `user` chunks become one OpenAI user message - consecutive `assistant` chunks become one assistant message - assistant `ThinkingChunk` values are joined into `reasoning_content` - assistant `ToolCall` values are exported into `tool_calls` ## Multimedia ### Image ```python from pagentv4 import ImageUrl, Message msg = Message(role="user", content=ImageUrl(type="image_url", url="https://...")) ``` Exports to: ```python {"type": "image_url", "image_url": {"url": "..."}} ``` ### Audio ```python from pagentv4 import AudioUrl, Message msg = Message( role="user", content=AudioUrl( type="audio_url", url="https://example.com/voice.wav", text="transcribed text", ), ) ``` Current export is a fallback mapping: - one media part for the remote audio URL - one text part for the transcript Media types supported by `pagentv4` and media types accepted by OpenAI-compatible APIs do not fully align yet. --- # pagentv4 Tools Tools in `pagentv4` are ordinary Python functions decorated with `@tool()`. The `Runner` executes them during the multi-turn loop. ## Define a tool ```python from pagentv4 import tool @tool() def get_weather(city: str) -> str: """Return weather for a city.""" return f"Sunny in {city} today." ``` The decorator derives: - tool name from the function name - description from the docstring - argument schema from type hints ## Use with `AgentCore` + `VanillaRunner` ```python from pagentv4 import AgentCore, DeepSeek, Messages, VanillaRunner agent = AgentCore( DeepSeek("deepseek-v4-flash"), system="Use tools when needed.", tools=[get_weather], ) messages = Messages() runner = VanillaRunner(agent, messages) async for event in runner.run("Weather in Xiamen?", return_type="event"): ... ``` ## Tool outputs Plain return values are wrapped into `ToolOutput(content=..., ok=True)`. To signal failure explicitly: ```python from pagentv4 import ToolOutput, tool @tool() def calc(expression: str) -> ToolOutput: """Evaluate a simple arithmetic expression.""" if not expression.strip(): return ToolOutput.fail("empty expression") return ToolOutput.succeed("42") ``` `ToolResult.ok` is exposed on the event stream. ## Argument handling `FunctionTool.call()` and `FunctionTool.acall()` accept: - `None`: call the tool with no arguments - JSON string: parse then call with `**payload` - mapping: call directly with `**arguments` Invalid JSON is converted into a failed `ToolOutput`. `call()` is synchronous and only supports plain functions. Async tools must use `acall()`; `Runner` does this automatically during a run. ## Async tools ```python @tool() async def fetch(city: str) -> str: """Fetch weather asynchronously.""" return f"Sunny in {city}" ``` Register the tool on `AgentCore` as usual. ## Sandbox tools When you use `Runner.create()` with a sandbox backend or bind a `Sandbox` manually, eight built-in tools are available: | Tool | Purpose | |------|---------| | `run_command` | Run a shell command in the workspace | | `read_file` | Read a file | | `write_file` | Write a file | | `str_replace` | Replace text in a file | | `list_dir` | List a directory | | `list_host_files` | List files on the host side of the workspace | | `copy_from_host` | Copy host file into the sandbox | | `copy_to_host` | Copy sandbox file to the host | Use `build_sandbox_tools(sandbox)` or `sandbox.tools()` to get them. `Runner.create()` merges sandbox tools with any extra tools you pass. ## Skills Skills are optional instruction packs loaded from `SKILL.md` directories. Use `SkillRegistry.from_defaults()` and `make_use_skill_tool(registry)` to let the model load skill instructions on demand. See `examples/app/repl.py`. ## Tool hooks `Runner.create(..., tool_hooks=...)` runs callbacks around each tool execution. Event order is unchanged: `ToolCallBegin` is emitted first, then hooks run, then the tool (or skip), then `ToolResult`. ```python from pagentv4 import Runner, ToolDecision, ToolHooks from pagentv4.runtime.hooks import ToolHookContext, PostToolHookContext async def approve_dangerous(ctx: ToolHookContext): if ctx.name == "run_command": ok = await ctx.runner.wait_tool_permit(ctx.tool_call_id) if not ok: return ToolDecision.deny("not permitted") return None # allow def redact_result(ctx: PostToolHookContext): return ctx.output # or return a new ToolOutput to replace runner = await Runner.create( "demo", provider, tool_hooks=ToolHooks(before=[approve_dangerous], after=[redact_result]), ) ``` - `ToolDecision.deny(message)` — skip execution, emit failed `ToolResult` - `ToolDecision.replace(content)` — skip execution, emit success with fixed content - `runner.permit_tool(id)` / `runner.deny_tool(id)` — or `runner.inbound.permit(id)` / `deny(id)` - `await ctx.runner.wait_tool_permit(id)` — consumes inbound `PermitTool` / `DenyTool` / `CancelRun` - `tool_hooks=None` — same behavior as before (zero overhead path) Inbound steer/cancel remains separate from hooks; see `runtime/inbound.py`. ## Notes - Tool names must be unique inside one `AgentCore`. - Keep docstrings short and concrete. The model sees them. - Tool calls use the OpenAI function-call shape. --- # pagentv4 Events `runner.run()` emits the full multi-turn timeline, projected by `return_type`. ## Event types | Event | Fields | Meaning | |-------|--------|---------| | `RunBegin` | `user_input` | A new run starts | | `RunEnd` | `turn`, `stop_reason` | Final run outcome | | `TurnBegin` | `turn` | One turn starts; see “What a `turn` means” below | | `TextDelta` | `text` | Assistant text chunk | | `ReasoningDelta` | `text` | Assistant reasoning chunk | | `TurnResult` | `content`, `tool_calls`, `reasoning_content` | Summary of the model output for this turn; not the end marker | | `ToolCallBegin` | `tool_call_id`, `name`, `arguments` | About to execute one tool | | `ToolResult` | `tool_call_id`, `name`, `content`, `ok` | Tool output appended | | `TurnEnd` | `turn`, `stopped`, `stop_reason` | Turn finished; see `StopReason` below | ## What a `turn` means In `pagentv4`, a `turn` is one internal work cycle the agent performs while handling a single user input. One turn includes these steps: - emit `TurnBegin` - call the model and produce `TextDelta`, `ReasoningDelta`, and possible tool calls - summarize that model output as `TurnResult` - if tools were requested, execute them in the same turn and emit `ToolCallBegin` and `ToolResult` - emit `TurnEnd` So: - a `turn` is not the same as one user message - a `turn` is not the same as one bare model call - a `turn` is the full unit of “one model generation + tool execution for that round + continue or stop decision” ## `TurnResult` vs `TurnEnd` These two events are easy to mix up: - `TurnResult`: summary of the model output for the current turn, used by the runner to decide what happens next - `TurnEnd`: the turn is actually finished, including tool execution and stop/continue resolution In other words, a turn may still be running after `TurnResult`. Only `TurnEnd` marks the real end of the turn. ## Typical sequence With tools: ```text RunBegin TurnBegin(0) TextDelta* ReasoningDelta* TurnResult(tool_calls=[...]) ToolCallBegin(...) ToolResult(...) TurnEnd(0, stopped=False, stop_reason="continuing") TurnBegin(1) TextDelta* TurnResult(tool_calls=[]) TurnEnd(1, stopped=True, stop_reason="no_tool_calls") RunEnd(1, stop_reason="no_tool_calls") ``` Without tools: ```text RunBegin TurnBegin(0) TextDelta* TurnResult(tool_calls=[]) TurnEnd(0, stopped=True, stop_reason="no_tool_calls") RunEnd(0, stop_reason="no_tool_calls") ``` ## `StopReason` | Value | `stopped` | Meaning | |-------|---------|---------| | `continuing` | `False` | Tools ran; another model turn will follow | | `no_tool_calls` | `True` | Model replied without tools; run ends | | `empty_response` | `True` | Model produced no assistant messages; run ends | | `max_turns` | `True` | `max_turns` limit reached after tool execution; run ends | | `cancelled` | `True` | Run cancelled by inbound control | ## Consumers ```python from pagentv4 import DeepSeek, Runner, TextDelta, ToolCallBegin, ToolResult runner = await Runner.create("demo", DeepSeek("deepseek-v4-flash"), overrides={"backend": "local"}) try: async for event in runner.run("Hello", return_type="event"): if isinstance(event, TextDelta): print(event.text, end="") elif isinstance(event, ToolCallBegin): print(f"\n[tool {event.name}]") elif isinstance(event, ToolResult): print(f"\n[result {event.ok}: {event.content}]") finally: await runner.close() ``` ## Other `return_type` projections `runner.run()` supports: - `"event"`: raw event objects - `"text"`: `TextDelta.text` only - `"message"`: `Message` objects projected from `TextDelta`, `ReasoningDelta`, `ToolCallBegin`, and `ToolResult` - `"acp"`: NDJSON JSON-RPC notifications via `encode_event_line()` The event stream is the canonical source of truth in `pagentv4`. --- # pagentv4 Sandbox A **sandbox** is the agent's companion computer: an isolated workspace where it can run commands and read/write files. Paths are normalized to a virtual home (default `/home/agent`) across all backends. ## Quick path: `Runner.create()` The simplest way to give an agent a computer: ```python from pagentv4 import DeepSeek, Runner runner = await Runner.create( "demo", DeepSeek("deepseek-v4-flash"), overrides={"backend": "local"}, ) try: async for event in runner.run( "List files under /home/agent, then create notes.md." ): ... finally: await runner.close() ``` Flow: 1. Open thread → create sandbox from thread spec 2. Bind sandbox tools + any extra tools passed to `create()` 3. Build `AgentCore` and run via `runner.run()` 4. Close sandbox with `runner.close()` ## Backends | `backend=` | Notes | |------------|-------| | `"local"` | Default. Thread workspace on host under `.pagent/threads//workspace/` | | `"docker"` | Container with bind mount | | `"podman"` | Same as docker, Podman CLI | | `"ssh"` | Remote host via asyncssh | ```python runner = await Runner.create( "demo", provider, overrides={"backend": "docker", "image": "python:3.12-slim"}, ) try: async for event in runner.run(user_input): ... finally: await runner.close() ``` SSH example — set `ssh_host` in thread spec or overrides: ```python runner = await Runner.create( "remote", provider, overrides={ "backend": "ssh", "ssh_host": "user@example.com", "ssh_workdir": "/tmp/agent", }, ) ``` ## Workspace layout With `thread_id="demo"`: ```text /.pagent/threads/demo/workspace/ ``` Persistent runners get their workspace from the thread. The sandbox maps agent paths under `/home/agent` to this directory. ## Direct `Sandbox` API For lower-level control, you can create a sandbox directly and choose a `workspace_id` or `workdir` yourself: ```python from pagentv4 import Sandbox sandbox = await Sandbox.create(backend="local", workspace_id="my-project") try: result = await sandbox.commands.run("ls -la") await sandbox.files.write("hello.txt", "hi") content = await sandbox.files.read_text("hello.txt") finally: await sandbox.close() ``` Context manager form: ```python async with await Sandbox.create(backend="local", workspace_id="demo") as box: await box.files.write("hello.txt", "hi") ``` ## Built-in agent tools `sandbox.tools()` returns eight `FunctionTool` instances (see [Tools](./tools)). Wording shown to the model avoids internal terms like "sandbox". ## Thread integration A [Thread](./core-types#thread) stores sandbox spec, messages, and workspace together under `.pagent/threads//`. Use this when you need the same computer and conversation to survive across process restarts — see `examples/pagentv4/runner/sandbox.py`. ## Limits `sandbox.commands.run(..., timeout=...)` and `SandboxLimits` cap stdout, stderr, memory, and CPU time. Defaults are conservative; tune per workload.