diff --git a/apps/website/content/docs/deep-agents/capabilities/filesystem.mdx b/apps/website/content/docs/deep-agents/capabilities/filesystem.mdx index 13270f994..9bde5b414 100644 --- a/apps/website/content/docs/deep-agents/capabilities/filesystem.mdx +++ b/apps/website/content/docs/deep-agents/capabilities/filesystem.mdx @@ -1,111 +1,122 @@ --- title: Filesystem -description: StateBackend keeps a Deep Agents workspace on the graph state, and a FilesystemPermission in interrupt mode routes writes through the chat interrupt panel. +description: How the Deep Agents filesystem example keeps the agent workspace on graph state and pauses writes under /reports/ for human approval --- # Filesystem -`create_deep_agent` always installs `FilesystemMiddleware`, so a Deep Agents agent always has `ls`, `read_file`, `write_file`, and `edit_file`. What decides whether a user interface can render that workspace is not the middleware. It is the backend. - -```python -from deepagents import create_deep_agent -from deepagents.backends import StateBackend -from deepagents.middleware import FilesystemPermission - -graph = create_deep_agent( - model=ChatOpenAI(model="gpt-4.1", temperature=0), - tools=[lookup_field_elevation, lookup_runway_length], - system_prompt=(PROMPTS_DIR / "filesystem.md").read_text(), - backend=StateBackend(), - permissions=[ - FilesystemPermission(operations=["write"], paths=["/reports/**"], mode="interrupt"), - ], -) -``` +`create_deep_agent` always installs `FilesystemMiddleware`, so a Deep Agents agent always has `ls`, `read_file`, `write_file`, and `edit_file`. What decides whether a user interface can render that workspace is not the middleware. It is the backend. The running example is a dispatch filing desk that gathers airport data, keeps working notes, and files a report, and this page walks the three files behind its workspace panel and its write approval. -`StateBackend` stores the agent's files on the graph state under `files`, which means every write arrives at the client as a `values` update. A backend that writes anywhere else — a host directory, a remote object store — puts nothing on the state, and a panel bound to `files` stays empty no matter how busy the agent is. The choice of backend is the choice of whether the workspace is renderable at all. +## What the demo does -## What the demo shows +The Run tab shows the prebuilt `` composition beside a workspace panel. The welcome suggestion, "Runway note for KASE", asks the agent to work up a runway suitability note: save the raw lookups to `/notes/kase-data.md`, then write the finished note to `/reports/kase-runway.md`. -The demo is the same dispatch desk, given a task that produces artifacts: gather field data for two airports, keep working notes, then file a report. +The scratch file under `/notes/` appears in the panel the moment the agent writes it. The report does not. A write under `/reports/` pauses the run, and an approval card appears below the tree while the target path is already listed as a dimmed, italic row badged "awaiting approval". -The workspace panel renders a directory tree grouped by path. Scratch files under `/notes/` appear the moment the agent writes them, with no ceremony. A write under `/reports/`, however, stops the run. +Accept lets the write land and the run continue. Ignore rejects it, and the agent finishes without the file. The card also offers Edit and Respond, which this example leaves unhandled. Selecting any file in the tree shows its contents in the preview underneath. -That is the `FilesystemPermission` above. In `interrupt` mode a matching call pauses for human approval instead of executing, and the pause surfaces through the standard chat interrupt panel — the same component every other LangGraph interrupt uses. There is no Deep Agents specific interrupt UI, because there is no Deep Agents specific interrupt. +## How it is built -Approving the write lets the run continue and the file lands in the tree. Rejecting it returns the model to work without the file. +Three files carry the feature: a Python graph that builds the agent, an application config that registers it, and an Angular component that projects the workspace and maps the approval buttons onto resume payloads. Open the Code tab to read them in place. - -Give the pattern a literal prefix, as `/reports/**` does. Bulk tools such as `ls`, `glob`, and `grep` decide whether to fire the permission based on whether their search subtree could overlap the anchored prefix. A fully unanchored pattern collapses to the root and fires on every listing, which turns an approval gate into an interruption on each directory read. - +### The lookups the notes are made of -## How it reaches the UI - -### The tree - -`files` is a flat map from absolute path to contents. Splitting each key on its last slash is enough to group it into directories. - -```ts -protected readonly files = computed(() => { - const raw = (this.agent.value() as Record | undefined)?.['files']; - const entries = new Map(); - if (raw && typeof raw === 'object') { - for (const [path, contents] of Object.entries(raw as Record)) { - entries.set(path, typeof contents === 'string' ? contents : JSON.stringify(contents)); - } - } - return [...entries.entries()].map(([path, contents]) => { - const slash = path.lastIndexOf('/'); - return { - path, - directory: slash > 0 ? path.slice(0, slash) : '/', - name: path.slice(slash + 1), - contents, - }; - }); -}); -``` +The agent needs something to write down. Two ordinary LangChain tools answer field elevation and runway length for a handful of ICAO codes, and the system prompt tells the agent to gather that data before it writes anything. + + + +Nothing about these tools is filesystem specific; they are the source of the content the agent files. + +### The backend that makes the workspace renderable + +`StateBackend` stores the agent's files on the graph state under `files`, so every write reaches the client as a `values` update. A backend that stores files anywhere else, such as a host directory or a remote store, puts nothing on the state, and a panel bound to `files` stays empty no matter how busy the agent is. `FilesystemPermission` is the second half: a rule over operations and path patterns, and in `interrupt` mode a matching call pauses for human approval instead of executing. -### The pending write + -While an approval is open, the file does not exist yet — it is an argument on a paused tool call. Reading it off the interrupt lets the tree show the file as a ghost row, so the reviewer sees where it is about to land before deciding. +`StateBackend` comes from `deepagents.backends` and `FilesystemPermission` from `deepagents.middleware`; no interrupt wiring is needed beyond the rule, because an interrupt-mode rule auto-installs `HumanInTheLoopMiddleware`. -The interrupt payload is `{ action_requests: [{ name, args }] }`, and for `write_file` the target path is `args.file_path`: +### The agent provider -```ts -protected readonly pendingPath = computed(() => { - for (const interrupt of this.agent.langGraphInterrupts() ?? []) { - const value = (interrupt as { value?: unknown }).value as - | { action_requests?: Array<{ args?: Record }> } - | undefined; - for (const request of value?.action_requests ?? []) { - const path = request.args?.['file_path']; - if (typeof path === 'string') return path; - } - } - return null; +`provideAgent()` registers the agent once for the whole application, and `provideChat({})` registers the configuration the `` composition reads, here left at its defaults. The example passes a factory because it resolves its connection details at runtime from the host that serves the demo. + + + +Your own application does not need the factory. Pass the values directly: + +```typescript +provideAgent({ + apiUrl: 'https://your-deployment.langgraph.app', + assistantId: 'da-filesystem', }); ``` -### The resume payload +`assistantId` must match the graph name in `langgraph.json`, here `da-filesystem`. -`deepagents` expects a structured decision, not a bare string and not a bare list: +### The pending write, read off the interrupt -```ts -protected onInterruptAction(action: InterruptAction): void { - if (action === 'accept') { - void this.agent.submit({ resume: { decisions: [{ type: 'approve' }] } }); - } else if (action === 'ignore') { - void this.agent.submit({ resume: { decisions: [{ type: 'reject' }] } }); - } -} -``` +While an approval is open the file does not exist yet. It is an argument on a paused tool call, so the only place to find it is the interrupt payload, which `injectAgent()` exposes as the `langGraphInterrupts()` Signal. The payload is `{ action_requests: [{ name, args }], review_configs: [...] }`, and for `write_file` the target path is `args.file_path`. + + + +Reading it lets the tree show the file before it lands, so the reviewer sees where the write is headed while deciding. + +### The file map, projected into a tree + +`files` is a flat map from absolute path to a file record; the text is on its `content` field, which is why the projection stringifies anything that is not already a string. `agent.value()` returns the live graph state that holds the map. The projection reads that map, adds the pending path as a ghost entry when one is open, and splits each key on its last slash to derive a directory and a name. + + -Passing a bare list raises a `TypeError` on the server rather than a validation error the browser can show, so the failure appears as a dead run rather than as a rejected submission. The shape is worth getting right the first time. +Because the panel is a projection of state rather than a replay of `write_file` calls, an edit that rewrites an existing file shows up as one changed file here and as two entries in a tool call log. -## Next steps +A second computed groups the flat list by directory, which is all the structure a tree needs. + + + +### The panel + +The sidebar renders the grouped tree, a preview of the selected file, and the interrupt panel. A pending row carries `data-pending`, which dims and italicizes it; the badge is rendered from the same `pending` flag. `` is the same component every other LangGraph interrupt uses. There is no Deep Agents specific approval component, because there is no Deep Agents specific interrupt. + + + +Keeping the tree and the approval in one sidebar is the point of the layout: the reviewer reads the destination and the decision in the same glance. + +### Resuming with a decision + +`` emits an `InterruptAction` of `accept`, `edit`, `respond`, or `ignore`, and the component turns the two it handles into resume payloads. `HumanInTheLoopMiddleware` resumes on an object with a `decisions` list, one decision per paused tool call, each `{ "type": "approve" }`, `{ "type": "edit" }`, or `{ "type": "reject" }`. + + + +The demo always sends exactly one decision, which is enough because only one write is ever paused here. The middleware rejects a resume whose decision count differs from the number of hanging tool calls, so a turn that batches two writes into one interrupt needs two decisions. + +The consequence is visible in the tree. On Accept the write lands, the ghost row stops being pending, and the preview shows the real file content. On Ignore the interrupt clears without a file being written, so the row that only ever existed as a projection of the pending path disappears. + + +The middleware reads `interrupt(request)["decisions"]`, so a bare list or a bare string raises a `TypeError` on the server rather than a validation error the browser can show. The failure appears as a dead run rather than as a rejected submission, so the shape is worth getting right the first time. + + +## Permission rules + +A `FilesystemPermission` carries three fields: the `operations` it covers, the `paths` it matches, and the `mode` it applies. Rules are evaluated in declaration order and the first match wins; a call that matches no rule is allowed. Subagents inherit the parent rules unless they declare `permissions` of their own, which replaces the parent set entirely. + +The three modes are `allow`, which lets the call proceed, `deny`, which returns a permission-denied error to the model, and `interrupt`, which pauses the call for human approval. Path patterns must start with `/` and may not contain `..`. + + +Give the pattern a literal prefix, as `/reports/**` does. Bulk tools such as `ls`, `glob`, and `grep` decide whether to fire the permission based on whether their search subtree could overlap the anchored prefix. A fully unanchored pattern collapses to the root and fires on every listing, which turns an approval gate into an interruption on each directory read. + -- [Planning](/docs/deep-agents/capabilities/planning) — the todo list the agent keeps while it files. -- [Skills](/docs/deep-agents/capabilities/skills) — the same backend machinery, mounted read-only. -- [Interrupts](/docs/langgraph/guides/interrupts) — the interrupt lifecycle underneath the approval. -- [Chat interrupt panel](/docs/chat/components/chat-interrupt-panel) — the component that renders the approval. +## What's Next + + + + The todo list the agent keeps while it files. + + + The same backend machinery, mounted read-only. + + + The interrupt lifecycle underneath the approval. + + + The component that renders the approval. + + diff --git a/apps/website/content/docs/deep-agents/capabilities/memory.mdx b/apps/website/content/docs/deep-agents/capabilities/memory.mdx index ffdc3bb10..038ec4524 100644 --- a/apps/website/content/docs/deep-agents/capabilities/memory.mdx +++ b/apps/website/content/docs/deep-agents/capabilities/memory.mdx @@ -1,124 +1,115 @@ --- title: Memory -description: MemoryMiddleware plus StoreBackend give an agent a cross-thread memory file, and memory_contents is private state that a panel has to reach deliberately. +description: A Deep Agents memory file the agent writes itself, kept in the LangGraph store so a new thread starts already knowing, and read back through private state. --- # Memory -Memory in Deep Agents is a file the agent maintains about itself. `memory=["/memories/AGENTS.md"]` installs `MemoryMiddleware`, which loads that file into the system prompt at the start of every turn and instructs the model to keep it current with `edit_file`. Nothing in the application parses the conversation for facts. The agent decides what is worth remembering. +Memory in Deep Agents is a file the agent maintains about you. `memory=["/memories/AGENTS.md"]` installs `MemoryMiddleware`, which loads that file and appends it to the system message with guidance telling the model to keep it current using `edit_file`. Nothing in the application parses the conversation for facts; the agent decides what is worth remembering, and the backend decides how long it lasts. Reading it back is the part that takes work, because `memory_contents` is private state. This page walks the three files of the running example. -The backend decides how long the memory lasts. +## What the demo does -```python -from deepagents import create_deep_agent -from deepagents.backends import StoreBackend +The Run tab shows a dispatch desk beside a panel titled Agent Memory, which starts out saying that nothing is remembered yet. The first suggested prompt tells the agent that you fly a Citation CJ3 out of KASE and always want briefings in bullet points, and the panel fills in with the lines the agent writes into `/memories/AGENTS.md`. -MEMORY_NAMESPACE = ("cockpit", "deep-agents-memory") +The second suggestion is the one that matters. Reload the page to start a genuinely new thread, then ask what the agent already knows about your operation: the file is still there, because it came from the LangGraph store rather than from the transcript. A small label under the panel heading says which of two sources the panel is reading, and that label turns out to be the interesting part. -graph = create_deep_agent( - model=ChatOpenAI(model="gpt-4.1", temperature=0), - system_prompt=(PROMPTS_DIR / "memory.md").read_text(), - backend=StoreBackend(namespace=lambda _runtime: MEMORY_NAMESPACE), - memory=["/memories/AGENTS.md"], -) -``` +## How it is built + +Three files carry the capability: a Python graph that builds the agent and republishes one private key, an application config, and the Angular component that renders the file. Open the Code tab to read them in place. + +### The names the graph and the panel agree on -`StoreBackend` writes into LangGraph's `BaseStore`, which is shared across threads. `StateBackend` would put the same file on the thread's own state, where a new conversation would never see it. Leaving `store` unset means "resolve the store from the graph execution context", which LangGraph Server supplies. +The memory file path, the store namespace, and the name of the custom stream event are module constants. The event name is the contract with the Angular panel, which listens for exactly that string. + + -The demo uses a fixed namespace tuple, so every visitor shares one memory. That is deliberate for a demo and wrong everywhere else. A real deployment derives the namespace from the caller's identity. +The namespace factory ignores its runtime argument, so every thread of this demo shares one memory. That is deliberate here and wrong everywhere else. A real deployment derives the namespace from the caller, for example `lambda rt: (rt.server_info.user.identity,)`. -## What the demo shows +### An agent that owns its memory file + +`create_deep_agent` takes the memory sources and the backend. `memory=[...]` installs `MemoryMiddleware` with the agent's own backend, so where the memory lives is decided by the backend argument rather than by the memory argument. -The demo is the dispatch desk with a memory panel beside it. Tell it your home base is Denver and that you fly a mid-size business jet, and the panel fills in as the agent writes to `/memories/AGENTS.md`. Start a genuinely new thread and the agent already knows both facts, because the file came from the store rather than from the transcript. + -The panel also labels which of two sources it is reading, which turns out to be the interesting part. +`StoreBackend` keeps files in LangGraph's `BaseStore`, which is scoped by namespace and shared across every thread. The default backend is `StateBackend`, which would put the same file on the thread's own state, where a new conversation would never see it. Leaving `store` unset means "resolve the store from the graph execution context", which the LangGraph server supplies. -What the agent records is a matter of prompt, not code: +### What the prompt allows into memory + +What the agent records is a matter of prompt, not code. The system prompt in `prompts/memory.md` is the whole policy: ```markdown `/memories/AGENTS.md` is yours. It is loaded into your context at the start of -every conversation, including conversations you have not had yet. +every conversation, including conversations you have not had yet, and it is the +only thing about you that survives a new thread. Write to it with `edit_file` whenever the user tells you something durable: -a home base, a fleet type, a standing preference, a correction. -Do not record one-off requests, small talk, or anything stale next week. -Never record credentials of any kind. +- a home base, a fleet type, an operating limitation +- a standing preference about how they want briefings written +- a correction to something you got wrong + +Keep it as a short markdown list under a `## Crew notes` heading. One line per +fact. Do not record one-off requests, small talk, or anything that will be stale +next week. Never record credentials of any kind. ``` The last line is not decoration. A memory file is a persistent, model-writable document, so what must never go into it belongs in the prompt explicitly. -## How it reaches the UI +### Announcing a private state key -Here the framework constrains the answer, and the constraint is worth stating rather than working around quietly. +`MemoryMiddleware` annotates `memory_contents` with `PrivateStateAttr`, which is `OmitFromSchema(input=True, output=True)`: the key is deliberately absent from the agent's declared input and output. That is right for a transcript, since the memory file is context for the model rather than conversation, and it is why this example does not leave the panel bound to `agent.value()` alone while a run is in flight. A small middleware announces the key on a channel the client does receive during the run. -`MemoryMiddleware` annotates `memory_contents` with `PrivateStateAttr`. That keeps the key out of the `values` stream — correct for a transcript, since the memory file is context for the model rather than conversation — and it is exactly why a panel bound to `agent.value()` shows nothing while the agent is working. The key **is** written to the checkpoint, so it does arrive, but only once the run settles and the client hydrates the latest state. + -For a live panel, the graph has to announce the key on a channel the client does receive. A small middleware does that: +This is an application-side shim rather than a framework change: the key stays private on the state and is simply announced alongside it. `get_stream_writer` raises outside a streaming context, which is why the emit is guarded. -```python -class MemoryVisibilityMiddleware(AgentMiddleware): - def _emit(self, state): - contents = state.get("memory_contents") - if contents is None: - return - try: - writer = get_stream_writer() - except (RuntimeError, KeyError): - # No streaming context. The value is still on the checkpoint, - # which is what the client's settle-time hydration reads. - return - writer({"name": MEMORY_EVENT, "data": {"memory_contents": contents}}) +### Providing the agent - def after_model(self, state, runtime): - self._emit(state) - return None -``` +`provideAgent()` from `@threadplane/langgraph` registers the agent at the application root, and `provideChat({})` registers the chat defaults. The example resolves its connection at runtime because the host that serves the demo decides which runtime is attached; your own application passes `apiUrl` and `assistantId` directly. -This is an application-side shim, not a framework change. The key stays private on the state; it is simply announced alongside it. - -### Two sources, and knowing which one you are on - -A custom event is a live signal and is not replayed when a thread is reopened. The checkpoint is durable but arrives only at settle. A panel that wants both reads both, and it is worth telling them apart rather than blending them: - -```ts -private readonly liveMemory = computed | null>(() => { - for (const event of [...this.agent.customEvents()].reverse()) { - if (event.name !== MEMORY_EVENT) continue; - const contents = (event.data as { memory_contents?: unknown } | undefined)?.[ - 'memory_contents' - ]; - if (contents && typeof contents === 'object') return contents as Record; - } - return null; -}); - -private readonly settledMemory = computed | null>(() => { - const contents = (this.agent.value() as Record | undefined)?.[ - 'memory_contents' - ]; - return contents && typeof contents === 'object' - ? (contents as Record) - : null; -}); - -protected readonly memorySource = computed<'live' | 'checkpoint' | 'none'>(() => { - const live = this.liveMemory(); - if (live && Object.keys(live).length > 0) return 'live'; - return this.settledMemory() ? 'checkpoint' : 'none'; -}); -``` + -Without the middleware the panel still fills in, just a beat later and only at settle. With it, the panel updates while the agent is still writing. `checkpoint` is also what a reopened thread looks like, so the label is genuinely informative rather than a debug artifact. +### Two sources for one panel - -The only assertion that proves cross-thread memory is a genuinely new thread that already knows. Clearing the panel and watching it refill proves the component works, not the store. - +A custom event is a live signal and is not replayed when a thread is reopened. The thread state is durable but arrives only when the client hydrates it, which the adapter does on first connect and again when a run completes. A panel that wants both reads both. -## Next steps + + +`agent.customEvents()` holds the custom events of the current run — the adapter clears it when a new run starts — so the live reader walks it newest first and takes the most recent payload under the event name the graph emits. `agent.value()` is the agent state the adapter projects from the latest checkpoint, which carries the key even though the run stream does not. + + + +Blending the two would hide the distinction. `live` means the visibility middleware announced the key during this run; `checkpoint` is what a reopened thread looks like, and also what the panel degrades to if the middleware is removed. + +### Rendering the file + +The panel shows the source label, then one block per remembered file: the path, then one row per non-empty line. + + + +## Proving the store rather than the panel + +The only assertion that proves cross-thread memory is a genuinely new thread that already knows. Clearing the panel and watching it refill proves that the component works, not that the store is doing the remembering. The example's end-to-end test submits the second prompt after a fresh page load, so the file it asserts on was written during a conversation that is no longer open. + + +The panel fills in either way, so contents alone do not tell you whether the live path is working. Asserting that the source label reads `live` is what keeps the visibility middleware honest. + -- [Skills](/docs/deep-agents/capabilities/skills) — the same private-state visibility problem, for `skills_metadata`. -- [Filesystem](/docs/deep-agents/capabilities/filesystem) — the state-backed workspace that does stream on its own. -- [Memory](/docs/langgraph/guides/memory) — the LangGraph store this capability is built on. +## What's Next + + + + The same private-state visibility problem, for `skills_metadata`. + + + The state-backed workspace the agent writes during a run. + + + Thread-scoped memory in graph state, and the Store API behind this page. + + + Checkpointers and thread storage, which is where the durable source comes from. + + diff --git a/apps/website/content/docs/deep-agents/capabilities/planning.mdx b/apps/website/content/docs/deep-agents/capabilities/planning.mdx index fe98b71b8..525c56fe6 100644 --- a/apps/website/content/docs/deep-agents/capabilities/planning.mdx +++ b/apps/website/content/docs/deep-agents/capabilities/planning.mdx @@ -5,100 +5,93 @@ description: TodoListMiddleware puts a todos array on the graph state, and an An # Planning -`TodoListMiddleware` gives the model one tool, `write_todos`, and declares one key on the graph state, `todos`. That is the entire capability. Everything a plan panel needs comes from those two facts. - -```python -from deepagents import create_deep_agent -from langchain.agents.middleware import TodoListMiddleware -from langchain_openai import ChatOpenAI - -graph = create_deep_agent( - model=ChatOpenAI(model="gpt-4.1", temperature=0), - tools=[lookup_field_elevation, lookup_runway_length, lookup_weather], - system_prompt=(PROMPTS_DIR / "planning.md").read_text(), - middleware=[TodoListMiddleware()], -) -``` +Planning in Deep Agents is one tool and one state key. `TodoListMiddleware` registers a `write_todos` tool and declares `todos` on the graph state, and every call to that tool replaces the whole list. A panel that renders `todos` therefore shows the plan the agent is working from, including the revisions it makes while the run is still going. The running example is an aviation dispatch desk, and this page walks the three files behind it. + +## What the demo does + +The Run tab shows the prebuilt `` composition beside a plan panel. The welcome suggestion asks for a dispatch brief from KSFO to KASE, and the agent writes its todo list before it looks anything up: the panel fills with pending rows, one row flips to in progress while the matching lookup runs, then that row completes and the next one starts. Aspen is the interesting half of that route, a field at 7,820 ft with a mountain wave advisory, so the agent usually appends a step once the data comes back and the panel changes shape mid-run. A route with no notable constraints usually produces a short list, so ask for a brief on such a route to watch a plan run straight through instead. + +## How it is built + +Three files plus the system prompt carry the capability: a Python graph holding the lookup tools and the todo middleware, an application config, and the Angular component that projects `todos` into a panel. Open the Code tab to read them in place. + +### The tools the plan is made of -`create_deep_agent` installs a default middleware set that already includes the todo list. The demo passes it explicitly anyway, so the source states which component owns `todos` rather than leaving a reader to infer it. +Each lookup answers one question about one airport from a fixed table, so a recorded run stays stable. The plan the agent writes is a sequence of these calls. -## What the demo shows + -The demo is an aviation dispatch desk. Ask it whether a mid-size business jet can operate out of Aspen and San Francisco on the same day, and the run has a visible shape: +### Building the agent on TodoListMiddleware -1. The agent writes a plan before it does any work. The panel fills with pending rows. -2. One row flips to in progress, the corresponding lookup tool runs, and the row completes. -3. When a lookup returns something the plan did not account for — a mountain wave advisory, a runway shorter than the aircraft needs — the agent rewrites the list mid-run. Rows are added, and the panel changes shape while the run is still going. +`create_deep_agent` assembles a LangGraph agent from a middleware stack. `TodoListMiddleware` is not one of the entries it assembles on its own, so the demo passes it: that single entry registers the `write_todos` tool and adds `todos` to the state schema. -Step 3 is the reason the panel is worth building. A plan that only ever appends is a progress bar. A plan the agent revises is a window into what the agent is actually reasoning about. + -Getting there needs a prompt, not more middleware. `TodoListMiddleware` supplies a tool, not a policy, and a model left to itself will fan out six parallel lookups and never write a todo. The demo's system prompt is explicit: +The middleware also appends its own usage guidance to the system message on every model call, so the model receives instructions about the tool that the demo never wrote. + +### The prompt that makes the plan visible + +The middleware supplies a tool, not a policy, and its tool description tells the model to skip the list when a request takes fewer than three steps. A demo whose whole point is a visible plan cannot leave that to chance, so the system prompt in `prompts/planning.md` is explicit about when to call the tool and how small each transition should be. ```markdown Your first action on any request is a call to `write_todos`. Do not call a lookup tool before the todo list exists. Write one todo per step. Mark exactly one todo `in_progress` before you start it and mark it `completed` -the moment it is done. Call `write_todos` again for each transition. +the moment it is done. Call `write_todos` again for each transition — do not +batch several completions into one call, and do not call `write_todos` in +parallel with itself. ``` -## How it reaches the UI - -`todos` is a public key on the graph state, so LangGraph streams it in the `values` channel and `@threadplane/langgraph` projects the latest snapshot into `agent.value()`. The panel is a `computed()` over that snapshot and nothing more. - -```ts -import { Component, computed } from '@angular/core'; -import { injectAgent } from '@threadplane/langgraph'; - -interface Todo { - content: string; - status: 'pending' | 'in_progress' | 'completed'; -} - -const TODO_STATUSES: Todo['status'][] = ['pending', 'in_progress', 'completed']; - -export class PlanningComponent { - protected readonly agent = injectAgent(); - - protected readonly todos = computed(() => { - const todos = (this.agent.value() as Record | undefined)?.['todos']; - if (!Array.isArray(todos)) return []; - return todos.map((todo) => { - const entry = todo as Record; - const status = entry['status'] as Todo['status']; - return { - content: String(entry['content'] ?? ''), - status: TODO_STATUSES.includes(status) ? status : 'pending', - }; - }); - }); -} -``` + +The tool replaces the entire list, so two calls in the same turn would be ambiguous about which one wins. `TodoListMiddleware` checks the assistant message after every model call and, when it finds more than one `write_todos` call, answers all of them with an error instead of applying any of them. The prompt asking for one transition at a time keeps the run away from that path. + -Two details in that projection are deliberate. +### Providing the agent -**The status is normalized.** A graph state key is not a typed contract. Narrowing an unknown status to `pending` keeps an unexpected value from reaching a template that switches on it. +`provideAgent()` from `@threadplane/langgraph` registers the agent at the application root, and `provideChat({})` registers the chat defaults with no overrides. The example resolves its connection at runtime because the host that serves the demo decides which runtime is attached; your own application passes `apiUrl` and `assistantId` directly. -**Every call to `write_todos` replaces the whole list.** There is no partial update and no merge, so the panel never has to reconcile anything. It renders the array it was given. + -The template tracks by index, because a todo carries no identifier: +### The shape of one todo -```html -@for (todo of todos(); track $index) { -
- {{ todo.content }} -
-} -``` +A todo is two fields. There is no identifier, no timestamp, and no separate present-tense label, so the component declares the shape it is willing to render and keeps the three valid statuses next to it. -Tracking by content would be worse, not better: content is exactly what changes when the agent rewrites a step. + - -In `deepagents` 0.7.11 a todo is exactly `{ content, status }`. There is no identifier, no timestamp, and no separate present-tense label. A panel that depends on any of those will not survive contact with the framework. - +### Projecting todos into a signal + +`injectAgent()` returns the agent, and `agent.value()` is a signal holding the latest graph state, so the panel is a `computed()` over that state and nothing more. The status is narrowed against the known list on the way through, because a graph state key is not a typed contract and an unexpected value would otherwise reach a template that switches on it. + + + +`completedCount` derives from `todos` rather than from the state again, so the progress line and the rows can never disagree. + +### The plan panel + +The panel renders the array it was given: an empty state before the first `write_todos` call, a progress line, and one row per todo. Status drives the icon through a `@switch` and the row styling through a `data-status` attribute, which keeps the status-to-style mapping in CSS instead of spreading a second copy of the enum through the template. Rows track by `$index` because a todo carries no identifier; tracking by content would be worse rather than better, since content is exactly what changes when the agent rewrites a step. + + + +## How todos reach the browser + +`todos` travels as graph state, not as a custom event. The adapter subscribes to the `values`, `messages-tuple`, `updates`, and `custom` stream modes by default, and each `values` payload becomes the new `agent.value()`. Nothing in the application subscribes to anything, and nothing merges: because `write_todos` replaces the list, the panel renders whatever the last snapshot held. + +The middleware declares `todos` as state the graph emits rather than as input a caller supplies. The plan is therefore something to read and render, never something the panel writes back — the model owns it from the first call to the last. -## Next steps +## What's Next -- [Subagents](/docs/deep-agents/capabilities/subagents) — the same orchestrator delegating each planned step to a child agent. -- [Filesystem](/docs/deep-agents/capabilities/filesystem) — the workspace the agent writes into while it works through the plan. -- [Streaming](/docs/langgraph/guides/streaming) — how the `values` channel reaches `agent.value()`. + + + The same orchestrator delegating a planned step to a child agent. + + + The workspace the agent writes into while it works through a plan. + + + A file the agent keeps about itself across threads. + + + How the values channel reaches `agent.value()`. + + diff --git a/apps/website/content/docs/deep-agents/capabilities/skills.mdx b/apps/website/content/docs/deep-agents/capabilities/skills.mdx index 8fbfcddb1..ff795c855 100644 --- a/apps/website/content/docs/deep-agents/capabilities/skills.mdx +++ b/apps/website/content/docs/deep-agents/capabilities/skills.mdx @@ -1,13 +1,31 @@ --- title: Skills -description: SkillsMiddleware loads only SKILL.md frontmatter into the prompt and leaves the body on disk. skills_metadata is private state a panel must reach for. +description: SkillsMiddleware puts only a skill's frontmatter in the system prompt and leaves the body on the backend until a request matches. --- # Skills -A skill is a folder with a `SKILL.md` whose YAML frontmatter carries a `name` and a `description`, following the [agentskills.io](https://agentskills.io) specification. `SkillsMiddleware` loads **only that frontmatter** into the system prompt — a short index the model can scan — and leaves the body on the filesystem until a request actually matches. +A skill is a folder with a `SKILL.md` whose YAML frontmatter carries a `name` and a `description`, following the [agentskills.io](https://agentskills.io) specification. `SkillsMiddleware` scans a mount for those folders, and puts only the frontmatter into the system prompt: a short entry per skill giving the name, the description, and the path to read for the full instructions. The body stays on the backend until a request matches. That two-stage load is what progressive disclosure means, and the running example is built to make both stages visible. -That two-stage load is what progressive disclosure means. The index costs a few tokens per skill. The procedure costs nothing until it is needed. +## What the demo does + +The Run tab shows the prebuilt `` composition beside a panel titled Skill Index, which is empty until a run begins. The welcome suggestion asks whether a mid-size jet can operate out of KASE, and the panel fills in before the answer does: both skills are listed, `runway-analysis` and `weather-brief`, each with its description and a line reading "body not read", because both frontmatter blocks are in the prompt and neither body has been opened. + +Then exactly one skill opens. `runway-analysis` picks up an outline, the path of its `SKILL.md` appears under it, and the path of the margin table that the `SKILL.md` sends the agent to appears a step later. The weather skill stays closed for the whole run. It is in the index so that a different question, the conditions at KSFO for example, has somewhere else to route. + +## How it is built + +Three files carry the capability: a Python graph that seeds the skill mount, builds the agent, and republishes the middleware's private state, `skills_metadata` and `skills_load_errors`, an application config, and the Angular component that renders the panel. Open the Code tab to read them in place. + +### The names the graph and the panel agree on + +The mount point, the store namespace, and the name of the custom stream event are module constants. `SkillsMiddleware` scans one level below the mount, so `/skills/runway-analysis/SKILL.md` is found and a deeper file is not. + + + +### A skill on disk + +The frontmatter is the part the model sees first, so the `description` is written as a matching rule rather than as a summary, and its `name` must match the directory the file sits in, which is what makes the folder the unit rather than the file. This is the whole of `skills/runway-analysis/SKILL.md`: ```markdown --- @@ -18,94 +36,123 @@ license: MIT # Runway Analysis +## When to use + +The user is asking whether an aircraft can safely operate from a specific +runway, or is comparing two airports for a trip. + ## Procedure -1. Get the field elevation and the longest runway length. -2. Read `/skills/runway-analysis/reference/margins.md` for the margin table. - Do not work from memory — the table is the authority. -3. Compare, then state the verdict and the two numbers you compared. +1. Get the field elevation and the longest runway length with + `lookup_field_elevation` and `lookup_runway_length`. +2. Read `/skills/runway-analysis/reference/margins.md` for the required margin + table. Do not work from memory — the table is the authority. +3. Compare the runway length against the required distance for the aircraft + class at that elevation. +4. State the verdict in one sentence, then give the two numbers you compared. + +## Reporting + +Always name the margin you applied. A verdict without the margin is not +reviewable. ``` -The `description` is doing the routing, so it is written as a matching rule rather than as a summary. Step 2 is the second stage of the disclosure: the reference file costs nothing until the `SKILL.md` sends the agent to it. +Step 2 is the second stage of the disclosure: the margin table costs nothing until the `SKILL.md` sends the agent to it. + +### Seeding the skill mount + +`SkillsMiddleware` reads through the agent's backend, and which backend is a deployment decision. This demo runs on a shared public deployment, so a host-filesystem backend is out. The bundled folders are read once at import into a process-local `InMemoryStore`, which keeps the skill content in version control without giving the agent the host. + + + +Nothing in the demo writes to the store after import, so `/skills/` stays stable for the whole run, even though the machinery serving it is the same `StoreBackend` a writable mount would use. + + +Seed the store at `/runway-analysis/SKILL.md`, not `/skills/runway-analysis/SKILL.md`. The composite removes the matched prefix before delegating and re-adds it to the paths it returns, so a store seeded with the prefix surfaces to the agent as `/skills/skills/runway-analysis/...` and the scan finds nothing. + + +### Mounting the skills beside the thread's own files -## What the demo shows +`skills=["/skills/"]` is what installs `SkillsMiddleware`, and it is installed with the agent's own backend, so where the skills live is decided by the `backend` argument rather than by the `skills` argument. `CompositeBackend` matches by path prefix, longest first: `/skills/` resolves to the seeded store, and everything else falls through to `StateBackend`, so notes the agent writes stay on the thread and never touch the skill mount. -The dispatch desk carries two skills, runway analysis and a weather brief. The panel lists both from the moment the run starts, because both frontmatter blocks are in the prompt. Ask a runway question and exactly one skill opens: the panel marks `runway-analysis` as read, its reference file is opened a step later, and the weather skill stays closed on disk. + -That closed skill is the demonstration. If every skill's files are read on every request, the index is not routing anything and the descriptions need work. +### Telling the model the index is an index -The prompt has to say that the index is an index: +The middleware appends its own guidance to the system message, including an instruction to read a skill's path with `read_file` before following it. The demo's own system prompt in `prompts/skills.md` says the same thing in the voice of the task, because a dispatcher that answers from recollection instead of from the margin table is the failure this example is about: ```markdown -Your procedures are not in this prompt — they are skills under `/skills/`. -When a request matches a skill, read its `SKILL.md` before you start, and -follow the procedure it gives you. If the `SKILL.md` points at another file, -read that too — the numbers in a reference file are the authority, and your -recollection is not. +You are a dispatcher. Your procedures are not in this prompt — they are skills on +your filesystem under `/skills/`, and each one is a folder with a `SKILL.md`. + +You have been given the name and description of every skill up front. That index +is deliberately short. When a request matches a skill, read its `SKILL.md` with +`read_file` before you start, and follow the procedure it gives you. If the +`SKILL.md` points at another file, read that too — the numbers in a reference +file are the authority, and your recollection is not. ``` -## Mounting the skills - -`SkillsMiddleware` reads through a backend, and which backend is a deployment decision. The demo seeds a process-local store from the repository and mounts it read-only, which keeps the skill content in version control without giving the agent the host. - -```python -graph = create_deep_agent( - model=ChatOpenAI(model="gpt-4.1", temperature=0), - tools=[lookup_field_elevation, lookup_runway_length, lookup_weather], - system_prompt=(PROMPTS_DIR / "skills.md").read_text(), - backend=CompositeBackend( - default=StateBackend(), - routes={"/skills/": StoreBackend(namespace=..., store=SKILLS_STORE)}, - ), - skills=["/skills/"], -) -``` +### Announcing the private state keys -`CompositeBackend` routes by path prefix, longest first. Anything outside `/skills/` falls through to `StateBackend`, so notes the agent writes stay on the thread and never touch the skill mount. +`SkillsMiddleware` declares `skills_metadata` and `skills_load_errors` annotated with `PrivateStateAttr`, so the loaded index is deliberately absent from the agent's declared input and output and never reaches the `values` stream. A panel bound to the settled state alone would therefore stay empty until the run finished. A small middleware announces both keys on a channel the client does receive while the run is going, guarded because `get_stream_writer` raises outside a streaming context. - -Seed the store at `/runway-analysis/SKILL.md`, not `/skills/runway-analysis/SKILL.md`. The composite removes the matched prefix before delegating and re-adds it to the result, so a store seeded with the prefix surfaces to the agent as `/skills/skills/runway-analysis/...` and the skill scan finds nothing. - + -## How it reaches the UI +This is the same application-side shim the [memory capability](/docs/deep-agents/capabilities/memory) uses, and it is worth naming as such rather than mistaking it for a framework feature. -The panel has two halves, and they arrive by different routes. +### Providing the agent -**The index is private state.** `skills_metadata` is annotated `PrivateStateAttr`, exactly as `memory_contents` is, so it is absent from the `values` stream and reaches `agent.value()` only once the run settles. A live index needs the same custom-event shim the [memory capability](/docs/deep-agents/capabilities/memory) documents: a small middleware republishes the key on the custom stream, and the client reads the custom event first and falls back to the settled state for a reopened thread. +`provideAgent()` from `@threadplane/langgraph` registers the agent at the application root, and `provideChat({})` registers the chat defaults. The example resolves its connection at runtime because the host that serves the demo decides which runtime is attached; your own application passes `apiUrl` and `assistantId` directly. -```ts -private readonly liveSkills = computed(() => { - for (const event of [...this.agent.customEvents()].reverse()) { - if (event.name !== SKILLS_EVENT) continue; - const metadata = (event.data as { skills_metadata?: unknown } | undefined)?.[ - 'skills_metadata' - ]; - if (Array.isArray(metadata)) return metadata as SkillMetadata[]; - } - return null; -}); -``` + -As with memory, this is an application-side shim rather than a framework feature, and it is worth naming as such. +### Two sources for one index -**What the agent opened needs no shim at all.** A skill body is read with `read_file`, which is an ordinary tool call: +A custom event is a live signal and is not replayed when a thread is reopened. The thread state is durable but arrives only when the client hydrates it. A panel that wants the index during the run and after a reload reads both. -```ts -private readonly openedPaths = computed(() => { - const paths: string[] = []; - for (const call of this.agent.toolCalls()) { - if (call.name !== 'read_file') continue; - const path = (call.args as Record | undefined)?.['file_path']; - if (typeof path === 'string' && !paths.includes(path)) paths.push(path); - } - return paths; -}); -``` + + +`agent.customEvents()` holds the custom events of the current run, cleared when a new run starts, so the live reader walks it newest first and takes the most recent payload under the event name the graph emits, while `agent.value()` is the agent state the adapter projects from the latest checkpoint, which carries the key even though the run stream does not. + +### What the agent actually opened + +The label the panel shows distinguishes the two sources rather than blending them: `live` means the visibility middleware announced the index during this run, and `checkpoint` is what a reopened thread looks like. The other half of the panel needs no shim at all. Reading a skill is a `read_file` call, an ordinary tool call on the runtime-neutral `agent.toolCalls()` Signal, so the paths are already on the client. -Matching those paths against each skill's directory is what makes the panel show the thing worth showing: one skill opened, the rest still on disk. + -## Next steps +Each entry of `skills_metadata` carries the `path` of its `SKILL.md`, and everything before the last slash of that path is the skill's directory, so matching the opened paths against that prefix is what attributes a read to a skill and is why the margin table counts as opening `runway-analysis` rather than as an unrelated file read. + + + +### The panel + +The sidebar renders the source label, then one card per skill: the name, the description the model was given, and either the paths read or the words "body not read". An opened card carries `data-opened="true"`, which is what draws the outline. + + + +Rendering both halves in one column is the point, because the index is what the prompt paid for and the read paths are what the request actually cost. + +## The skill that stays closed + +The strongest evidence that a skills setup is working is the skill that does not get opened. If every skill's files are read on every request, the index is not routing anything and the descriptions are doing no work. That makes the descriptions, not the procedures, the part to iterate on: they are the only text the model sees before it chooses. + + +A test that only checks that `runway-analysis` was read passes just as happily when the agent reads everything. The example's end-to-end test asserts the negative too: `weather-brief` still carries `data-opened="false"` when the run is over. + -- [Memory](/docs/deep-agents/capabilities/memory) — the same private-state visibility problem, in full. -- [Filesystem](/docs/deep-agents/capabilities/filesystem) — the backends the skill mount is assembled from. -- [Chat tool calls](/docs/chat/components/chat-tool-calls) — how the `read_file` calls render in the conversation. +## What's Next + + + + The same private-state visibility problem, for `memory_contents`. + + + The backends the skill mount is assembled from, and approval on writes. + + + Delegating a procedure to a context of its own. + + + How the `read_file` calls render in the conversation itself. + + diff --git a/apps/website/content/docs/deep-agents/capabilities/subagents.mdx b/apps/website/content/docs/deep-agents/capabilities/subagents.mdx index 54ec29086..64f44c135 100644 --- a/apps/website/content/docs/deep-agents/capabilities/subagents.mdx +++ b/apps/website/content/docs/deep-agents/capabilities/subagents.mdx @@ -1,86 +1,110 @@ --- title: Subagents -description: SubAgentMiddleware dispatches child graphs through a task tool, which the Threadplane subagent tracker recognizes by default with no client configuration. +description: Passing subagents to create_deep_agent puts your specialists on the task tool, and every dispatch runs as a real child graph that renders as its own card. --- # Subagents -`SubAgentMiddleware` gives an orchestrator a single tool, `task`, taking `{ description, subagent_type }`. Each dispatch runs a real child graph in its own `tools:` namespace, and the child is seeded with the orchestrator's `description` before it emits its first token. +Delegation in Deep Agents is one tool. `SubAgentMiddleware` registers a tool named `task` taking `{ description, subagent_type }`, and each call runs a real child graph with its own system prompt, its own tools, and its own transcript. Nothing about that is special on the wire — it is an ordinary tool call — and yet the browser renders it as a child agent, because `task` is the name the subagent tracker already watches for. The running example is an aviation dispatch desk, and this page walks the three files behind it. -```python -from deepagents import SubAgent, create_deep_agent +## What the demo does -FIELD_RESEARCHER: SubAgent = { - "name": "field-researcher", - "description": "Gathers field elevation and runway length for one airport.", - "system_prompt": "You research airport field data for a dispatch desk. ...", - "tools": [lookup_field_elevation, lookup_runway_length], -} +The Run tab shows the prebuilt `` composition beside a sidebar holding the specialist roster and a live dispatch count. The first welcome suggestion asks for a brief on KASE and KDEN, covering field data and weather at both, and the orchestrator answers by dispatching four specialists in a single turn: four cards appear in the conversation at once, each expanded while its child works and collapsed to its header row when it reports. The second suggestion asks for the field data at KSFO alone, which is one dispatch and one card, so the two suggestions are the fan-out and the single case side by side. The orchestrator has no lookup tools of its own, so there is no run in which it answers without delegating. -graph = create_deep_agent( - model=ChatOpenAI(model="gpt-4.1", temperature=0), - system_prompt=(PROMPTS_DIR / "subagents.md").read_text(), - subagents=[FIELD_RESEARCHER, WEATHER_ANALYST], -) -``` +## How it is built + +Three files carry the capability: a Python graph holding the lookup tools, the specialist specs, and the agent; an application config; and the Angular component that reads the dispatches into a sidebar. Open the Code tab to read them in place. + +### The tools the specialists own + +Each lookup answers one question about one airport from a fixed table, so a recorded run stays stable. These tools belong to the children, never to the orchestrator. + + + +### Declaring a specialist -Passing `subagents` is what installs the middleware and, with it, the `task` tool. The demo's orchestrator is given no lookup tools of its own, so it has no way to answer a question without delegating. +A `SubAgent` is a `TypedDict` with three required fields and a handful of optional ones. `name` is what the orchestrator passes as `subagent_type`, `description` is what the orchestrator reads when it decides where to send work, and `system_prompt` governs the child once it starts. Handing each spec its own `tools` is what splits the two jobs apart. -## What the demo shows + -Ask the dispatch desk about two airports at once and the run fans out. Two `task` calls go out in a single turn, two child agents work in parallel, and two subagent cards stream side by side in the conversation — each with its own transcript, its own tool calls, and no cross-wiring between them. +Omitting `tools` would inherit the main agent's tools instead, which is the opposite of what this demo wants. + +### Installing the task tool + +`create_deep_agent` assembles a middleware stack, and `SubAgentMiddleware` is already on it: the factory adds a `general-purpose` subagent unless that one is explicitly disabled, so the `task` tool ships by default. Passing `subagents` is what puts your own specialists on that tool. The demo passes no tools of its own, so delegation is the only path to an answer. + + + + +Unless it is explicitly disabled, `create_deep_agent` inserts a `general-purpose` subagent ahead of the specs you pass, with the main agent's model and tools. The `task` tool description therefore lists three agent types rather than two. The demo does not disable it; the system prompt simply names the two specialists it wants, which is enough to keep the orchestrator on them. + -The fan-out needs a prompt. A model left to itself will serialize dispatches, so one line in the orchestrator's system prompt changes the shape of the run: +### Asking for the dispatches in one turn + +The middleware supplies a tool, not a policy. A model left to itself will dispatch one specialist, wait for the report, and dispatch the next, which is correct but produces a run with nothing to see. Two paragraphs in `prompts/subagents.md` change the shape of the run instead. ```markdown +Give each dispatch a `description` that names the airport and says exactly what +you want back. One airport per dispatch — never ask a specialist to cover two. + When a request covers more than one airport or more than one kind of data, -issue every dispatch you need in a single turn so the specialists work in +issue every dispatch you need **in a single turn** so the specialists work in parallel. Do not wait for one to report before sending the next. ``` -## How it reaches the UI +Both halves matter, and the first one matters for a reason worth reading the next section for. -This is the capability that needs the least work on the client, because it needs none. +### Providing the agent -`task` is an ordinary tool call on the wire. What makes it render as a child agent is that the Threadplane subagent tracker recognizes the name — and `['task']` is the tracker's **default** `subagentToolNames`. A Deep Agents graph therefore lights the subagent cards with no configuration at all. Naming it explicitly is still worth doing as documentation, and it is required only when a dispatch tool is called something else: +`provideAgent()` from `@threadplane/langgraph` registers the agent at the application root, and `provideChat({})` registers the chat defaults with no overrides. `subagentToolNames: ['task']` is already the default, so the line changes nothing at runtime and is there to say which tool call means that a child started. The example resolves its connection at runtime because the host that serves the demo decides which runtime is attached; your own application passes `apiUrl` and `assistantId` directly. -```ts -provideAgent({ - apiUrl: environment.langGraphApiUrl, - assistantId: environment.assistantId, - // The default. Set it when your dispatch tool is named something else. - subagentToolNames: ['task'], -}); -``` + -### Why parallel dispatches attribute cleanly +### Reading the dispatches -The tracker registers a dispatch from the `task` tool call itself — including the `subagent_type` argument, which becomes the card's name — and then matches the child's `tools:` namespace exactly. Because the dispatch is registered before the child emits anything, attribution is structural rather than a guess from message ordering or text similarity. Two children running at the same time land in two cards because their namespaces differ, not because their output happens to look different. +`injectAgent()` returns the agent, and `agent.subagents()` is a signal holding a map of the dispatches in the current thread. The sidebar spreads that map into an array once and derives both numbers from it, so the two counts can never disagree. -Each dispatch also carries the orchestrator's `description` verbatim, and the match on it is exact. Two dispatches with identical descriptions are still distinguished by namespace, but a description that names its subject makes the card readable as well as correct. + -### What to put in the sidebar +`status` is itself a signal on each record, so it is called rather than read. -The `` composition already renders each dispatch as a `` inline and keeps it, collapsed, after completion. A separate tray of active subagents would duplicate that. The demo spends the sidebar on the one thing the cards do not show, which is how wide the fan-out went: +### The sidebar -```ts -private readonly dispatches = computed(() => [...this.agent.subagents().values()]); +The `` composition already renders every dispatch as a card inside the conversation, so a second tray of the same cards would only duplicate it. The sidebar spends its space on the two things the cards do not show: how wide the fan-out went, and who the specialists are. -protected readonly dispatchCount = computed(() => this.dispatches().length); + -protected readonly runningCount = computed( - () => this.dispatches().filter((subagent) => subagent.status() === 'running').length, -); -``` +## How a dispatch becomes a card -Note that `status` is itself a signal on the `Subagent` record, so it is called rather than read. +Two independent things have to happen for a `task` call to render as a child agent, and they come from opposite ends of the run. - -A dispatch `description` is both the child's opening instruction and the label a reader sees on the card. A vague description costs twice: the child starts with less to go on, and the card says less about what is happening. +The first is registration. When the orchestrator's message carries a `task` tool call, the tracker records a dispatch keyed by the tool-call id, reading `subagent_type` out of the arguments. That name becomes the card's label, and a call with no usable `subagent_type` is ignored rather than tracked. Registration is also what makes the card its own thing in the transcript: `` groups adjacent calls of the same name into one collapsed strip, but a call that spawned a subagent is always its own group, so four `task` calls in one turn stay four cards instead of collapsing into a single grouped strip. A dispatch is invisible while it is still `pending` — the map behind `subagents()` filters those out — so those four calls briefly render as one grouped tool-call strip before their cards appear. + +The second is attribution: deciding which child stream belongs to which dispatch. This is the part that is not free. A child runs under a `tools:` namespace whose id is a checkpoint identifier assigned independently of the parent's tool-call id, and the two are not linked anywhere on the wire. So the adapter matches on content instead. `SubAgentMiddleware` seeds each child with a single human message whose content is the dispatch `description`, verbatim, and the adapter matches that first message against the `description` argument of each unclaimed dispatch — exactly first, then by containment either way. + + +The deepagents `task` tool cannot announce its binding, so inside this graph the description is the only signal that survives fan-out; a graph that announces its children through the [Threadplane middleware](/docs/langgraph/guides/subgraphs) gets an exact binding instead. With one child outstanding the adapter can fall back to claiming the single unmatched dispatch, but with four outstanding at once there is nothing positional to fall back on: arrival order is not dispatch order, and guessing would put one specialist's transcript in another specialist's card. Descriptions that name their airport keep the four apart. Two dispatches whose descriptions are identical give the ladder nothing to tell them apart: the exact rung claims the first unmapped dispatch, so the second child lands on the wrong card rather than on none. -## Next steps +Until a stream is attributed its chunks are held rather than dropped, and they are replayed into the card the moment the match lands. A dispatch that never matches shows an empty card, which is a worse card but never a wrong one. + + +A dispatch `description` is both the child's opening instruction and the key the adapter attributes its output by. A vague description costs twice: the child starts with less to go on, and its stream is harder to tell from its siblings. + -- [Planning](/docs/deep-agents/capabilities/planning) — the orchestrator's own todo list, which pairs naturally with delegation. -- [Chat subagent card](/docs/chat/components/chat-subagent-card) — the card component on its own. -- [Subgraphs](/docs/langgraph/guides/subgraphs) — how namespaced child execution is attributed underneath the tracker. +## What's Next + + + + The orchestrator's own todo list, which pairs naturally with delegation. + + + The card component on its own, including what it renders for a child. + + + How namespaced child execution is attributed underneath the tracker. + + + The workspace the agent writes into while it works. + + diff --git a/apps/website/src/lib/docs-example-code.spec.ts b/apps/website/src/lib/docs-example-code.spec.ts index 4bceb8101..3fcdac560 100644 --- a/apps/website/src/lib/docs-example-code.spec.ts +++ b/apps/website/src/lib/docs-example-code.spec.ts @@ -34,11 +34,6 @@ const PENDING_PAGES = new Set([ '/docs/chat/guides/generative-ui', '/docs/chat/guides/theming', '/docs/chat/guides/thread-routing', - '/docs/deep-agents/capabilities/filesystem', - '/docs/deep-agents/capabilities/memory', - '/docs/deep-agents/capabilities/planning', - '/docs/deep-agents/capabilities/skills', - '/docs/deep-agents/capabilities/subagents', '/docs/runtimes/aws-strands/overview', '/docs/runtimes/mastra/overview', '/docs/runtimes/microsoft-agent-framework/overview', diff --git a/cockpit/deep-agents/filesystem/angular/src/app/filesystem.component.ts b/cockpit/deep-agents/filesystem/angular/src/app/filesystem.component.ts index 6be917bb0..b77af184f 100644 --- a/cockpit/deep-agents/filesystem/angular/src/app/filesystem.component.ts +++ b/cockpit/deep-agents/filesystem/angular/src/app/filesystem.component.ts @@ -68,6 +68,7 @@ const SUGGESTIONS = [ } +

Workspace

@if (files().length === 0) { @@ -103,6 +104,7 @@ const SUGGESTIONS = [

Approval

+ `, styles: [ @@ -197,6 +199,7 @@ export class FilesystemComponent { private readonly manualSelection = signal(null); + // #region pending-path /** * The path a pending `write_file` approval would create. * @@ -215,7 +218,9 @@ export class FilesystemComponent { } return null; }); + // #endregion + // #region files /** Live projection of `state.files`, plus a ghost row for a pending write. */ protected readonly files = computed(() => { const raw = (this.agent.value() as Record | undefined)?.['files']; @@ -241,7 +246,9 @@ export class FilesystemComponent { }) .sort((a, b) => a.path.localeCompare(b.path)); }); + // #endregion + // #region tree /** Files grouped by directory, so the panel reads as a tree. */ protected readonly tree = computed(() => { const groups = new Map(); @@ -254,6 +261,7 @@ export class FilesystemComponent { .sort(([a], [b]) => a.localeCompare(b)) .map(([directory, files]) => ({ directory, files })); }); + // #endregion /** The clicked file, defaulting to the most recently written one. */ protected readonly selectedPath = computed(() => { @@ -273,6 +281,7 @@ export class FilesystemComponent { this.manualSelection.set(path); } + // #region resume protected onInterruptAction(action: InterruptAction): void { if (action === 'accept') { void this.agent.submit({ resume: { decisions: [{ type: 'approve' }] } }); @@ -281,6 +290,7 @@ export class FilesystemComponent { } // 'edit' and 'respond' would need the tool args echoed back; out of scope here. } + // #endregion protected send(text: string): void { void this.agent.submit({ message: text }); diff --git a/cockpit/deep-agents/filesystem/python/docs/guide.md b/cockpit/deep-agents/filesystem/python/docs/guide.md deleted file mode 100644 index acd504a6e..000000000 --- a/cockpit/deep-agents/filesystem/python/docs/guide.md +++ /dev/null @@ -1,135 +0,0 @@ -# Filesystem with Deep Agents - - -Render the agent's workspace and gate the writes that matter. `StateBackend` keeps the -agent's files on the graph state under `files`, so a file tree is a `computed()` projection -of live state rather than a replay of `write_file` calls. A `FilesystemPermission` in -`interrupt` mode pauses writes under a chosen prefix for human approval. - - - -Add a workspace panel beside the `` component. Read the `files` map off -`injectAgent().value()`, group the paths into directories, and render -`` for the write approvals. - - - -This guide assumes `provideAgent()` is already configured. If it is not, work through the -[LangGraph quickstart](/docs/langgraph/getting-started/quickstart) first. - - - - - -`create_deep_agent` always installs `FilesystemMiddleware`, so the agent always has -`ls`, `read_file`, `write_file`, and `edit_file`. What decides whether a UI can render the -workspace is the backend. `StateBackend` stores files on the graph state, which means every -write arrives at the client as a `values` update: - -```python -from deepagents import create_deep_agent -from deepagents.backends import StateBackend - -graph = create_deep_agent( - model=ChatOpenAI(model="gpt-4.1", temperature=0), - tools=[lookup_field_elevation, lookup_runway_length], - system_prompt=(PROMPTS_DIR / "filesystem.md").read_text(), - backend=StateBackend(), -) -``` - -A backend that writes anywhere else — a host directory, a remote store — streams nothing -onto the state, and a panel bound to `files` stays empty no matter how busy the agent is. - - - - -`FilesystemPermission` is a rule over operations and path patterns. In `interrupt` mode a -matching call pauses for approval instead of executing: - -```python -from deepagents.middleware import FilesystemPermission - -permissions=[ - FilesystemPermission(operations=["write"], paths=["/reports/**"], mode="interrupt"), -] -``` - -Anchor the pattern with a literal prefix. Bulk tools (`ls`, `glob`, `grep`) decide whether to -fire based on whether their search subtree could overlap the anchored prefix, so a fully -unanchored pattern collapses to `/` and fires on every listing. - - - - -`files` is a flat map from absolute path to contents. Split each key on its last slash to get -a directory grouping: - -```typescript -protected readonly files = computed(() => { - const raw = (this.agent.value() as Record | undefined)?.['files']; - const entries = new Map(); - if (raw && typeof raw === 'object') { - for (const [path, contents] of Object.entries(raw as Record)) { - entries.set(path, typeof contents === 'string' ? contents : JSON.stringify(contents)); - } - } - return [...entries.entries()].map(([path, contents]) => { - const slash = path.lastIndexOf('/'); - return { path, directory: slash > 0 ? path.slice(0, slash) : '/', name: path.slice(slash + 1), contents }; - }); -}); -``` - - - - -The interrupt payload is `{ action_requests: [{ name, args }] }`, and for `write_file` the -target path is `args.file_path`. Reading it off `langGraphInterrupts()` lets the tree show the -file as a ghost row while the approval is open: - -```typescript -protected readonly pendingPath = computed(() => { - for (const interrupt of this.agent.langGraphInterrupts() ?? []) { - const value = (interrupt as { value?: unknown }).value as - | { action_requests?: Array<{ args?: Record }> } - | undefined; - for (const request of value?.action_requests ?? []) { - const path = request.args?.['file_path']; - if (typeof path === 'string') return path; - } - } - return null; -}); -``` - - - - -`deepagents` expects a structured decision, not a bare string: - -```typescript -protected onInterruptAction(action: InterruptAction): void { - if (action === 'accept') { - void this.agent.submit({ resume: { decisions: [{ type: 'approve' }] } }); - } else if (action === 'ignore') { - void this.agent.submit({ resume: { decisions: [{ type: 'reject' }] } }); - } -} -``` - -Passing a bare list, or a plain string, raises a `TypeError` on the server rather than a -validation error you can see in the browser. - - - - - -The pending row and the approval panel belong in the same sidebar. Reviewing a write is much -easier when the tree already shows where the file is about to land. - - - -- [Deep Agents Planning](/deep-agents/core-capabilities/planning/overview/python) — the todo list the agent keeps while it files -- [Chat Interrupts](/chat/core-capabilities/interrupts/overview/python) — the interrupt panel on its own - diff --git a/cockpit/deep-agents/filesystem/python/src/graph.py b/cockpit/deep-agents/filesystem/python/src/graph.py index b5948e9cd..0024d6050 100644 --- a/cockpit/deep-agents/filesystem/python/src/graph.py +++ b/cockpit/deep-agents/filesystem/python/src/graph.py @@ -44,6 +44,7 @@ } +# region lookup-tools @tool def lookup_field_elevation(airport: str) -> str: """Return the field elevation in feet for a four-letter ICAO airport code.""" @@ -60,8 +61,10 @@ def lookup_runway_length(airport: str) -> str: if length is None: return f"No runway data on file for {airport.upper()}." return f"{airport.upper()} longest runway is {length} ft." +# endregion +# region agent def build_filesystem_agent(): """Build the filesystem agent. @@ -80,6 +83,7 @@ def build_filesystem_agent(): FilesystemPermission(operations=["write"], paths=["/reports/**"], mode="interrupt"), ], ) +# endregion graph = build_filesystem_agent() diff --git a/cockpit/deep-agents/memory/angular/src/app/memory.component.ts b/cockpit/deep-agents/memory/angular/src/app/memory.component.ts index f9263d7c6..8ead7992b 100644 --- a/cockpit/deep-agents/memory/angular/src/app/memory.component.ts +++ b/cockpit/deep-agents/memory/angular/src/app/memory.component.ts @@ -67,6 +67,7 @@ const SUGGESTIONS = [ }
+

Agent Memory

@@ -88,6 +89,7 @@ const SUGGESTIONS = [ new conversation and it will still be here.

+ `, styles: [ @@ -159,6 +161,7 @@ export class MemoryComponent { protected readonly suggestions = SUGGESTIONS; + // #region memory-sources /** Latest `memory_contents` announced on the custom stream. */ private readonly liveMemory = computed | null>(() => { for (const event of [...this.agent.customEvents()].reverse()) { @@ -186,7 +189,9 @@ export class MemoryComponent { ? (contents as Record) : null; }); + // #endregion + // #region memory-source-label /** * Which of the two sources the panel is currently showing. * @@ -200,6 +205,7 @@ export class MemoryComponent { if (live && Object.keys(live).length > 0) return 'live'; return this.settledMemory() ? 'checkpoint' : 'none'; }); + // #endregion protected readonly memoryFiles = computed(() => { const live = this.liveMemory(); diff --git a/cockpit/deep-agents/memory/python/docs/guide.md b/cockpit/deep-agents/memory/python/docs/guide.md deleted file mode 100644 index de4c6747e..000000000 --- a/cockpit/deep-agents/memory/python/docs/guide.md +++ /dev/null @@ -1,126 +0,0 @@ -# Memory with Deep Agents - - -Give an agent a memory file it maintains itself, keep that file in LangGraph's store so it -outlives the thread, and render it. The interesting part is reading it back: -`memory_contents` is annotated `PrivateStateAttr`, so it never appears in the `values` stream -and needs a deliberate channel. - - - -Add an agent-memory panel beside the `` component. Read `memory_contents` from -`agent.customEvents()` for the live view and from `agent.value()` for a reopened thread, and -render each remembered line. - - - -This guide assumes `provideAgent()` is already configured. If it is not, work through the -[LangGraph quickstart](/docs/langgraph/getting-started/quickstart) first. - - - - - -`memory=[...]` installs `MemoryMiddleware`, which loads those files into the system prompt on -every turn and instructs the model to keep them current with `edit_file`. The backend decides -how long that survives: - -```python -from deepagents import create_deep_agent -from deepagents.backends import StoreBackend - -MEMORY_NAMESPACE = ("cockpit", "deep-agents-memory") - -graph = create_deep_agent( - model=ChatOpenAI(model="gpt-4.1", temperature=0), - system_prompt=(PROMPTS_DIR / "memory.md").read_text(), - backend=StoreBackend(namespace=lambda _runtime: MEMORY_NAMESPACE), - memory=["/memories/AGENTS.md"], -) -``` - -`StoreBackend` writes into LangGraph's `BaseStore`, which is shared across threads; -`StateBackend` would put the file on the thread's own state, where a new conversation would -never see it. `store=None` means "resolve the store from the graph execution context", which -the LangGraph server supplies. Scope the namespace per user in anything real — a fixed tuple -means every visitor shares one memory. - - - - -Nothing in the application parses the conversation for facts. The system prompt is the policy: - -```markdown -`/memories/AGENTS.md` is yours. It is loaded into your context at the start of -every conversation, including conversations you have not had yet. - -Write to it with `edit_file` whenever the user tells you something durable: -a home base, a fleet type, a standing preference, a correction. - -Do not record one-off requests, small talk, or anything stale next week. -Never record credentials of any kind. -``` - -The last line matters. A memory file is a persistent, model-writable document, so say plainly -what must never go in it. - - - - -`MemoryMiddleware` annotates `memory_contents` with `PrivateStateAttr`. That keeps it out of -the `values` stream — correct for a transcript, and the reason a panel bound to -`agent.value()` shows nothing while the agent is working. A small middleware announces it on a -channel the client does receive: - -```python -class MemoryVisibilityMiddleware(AgentMiddleware): - def _emit(self, state): - contents = state.get("memory_contents") - if contents is None: - return - try: - writer = get_stream_writer() - except (RuntimeError, KeyError): - return - writer({"name": MEMORY_EVENT, "data": {"memory_contents": contents}}) - - def after_model(self, state, runtime): - self._emit(state) - return None -``` - -This is an application-side shim, not a framework change: the key stays private on the state -and is simply announced alongside it. - - - - -A custom event is a live signal — it is not replayed when a thread is reopened. The key IS -written to the checkpoint, though, and `@threadplane/langgraph` projects the latest checkpoint -into `value()` when a run completes. So there are two sources, and it is worth telling them -apart: - -```typescript -protected readonly memorySource = computed<'live' | 'checkpoint' | 'none'>(() => { - const live = this.liveMemory(); - if (live && Object.keys(live).length > 0) return 'live'; - return this.settledMemory() ? 'checkpoint' : 'none'; -}); -``` - -Without the middleware the panel still fills in, just a beat later and only at settle. With it, -the panel updates while the agent is still writing. - - - - - -Test cross-thread memory by starting a genuinely new thread, not by clearing the panel. A -reload that creates a new thread and still shows the file is the only assertion that proves the -store, rather than the component, is doing the remembering. - - - -- [Deep Agents Skills](/deep-agents/core-capabilities/skills/overview/python) — the same private-state visibility problem, for `skills_metadata` -- [Deep Agents Filesystem](/deep-agents/core-capabilities/filesystem/overview/python) — the state-backed workspace that does stream - diff --git a/cockpit/deep-agents/memory/python/src/graph.py b/cockpit/deep-agents/memory/python/src/graph.py index d18283a0c..dc5c45335 100644 --- a/cockpit/deep-agents/memory/python/src/graph.py +++ b/cockpit/deep-agents/memory/python/src/graph.py @@ -14,8 +14,8 @@ `PrivateStateAttr`, so it never appears in the `values` stream and a panel bound to `agent.value()` would stay empty. `MemoryVisibilityMiddleware` below republishes it as a `custom` stream event, which does reach the client. Custom -events are live-only, so the client also hydrates from `getState` when a thread -is reopened. +events are live-only, so when a thread is reopened, the value comes from +thread history instead, which is what the client's history hydration reads. """ from pathlib import Path @@ -29,6 +29,7 @@ PROMPTS_DIR = Path(__file__).parent.parent / "prompts" +# region memory-constants MEMORY_FILE = "/memories/AGENTS.md" #: Fixed namespace: every thread of this demo shares one memory. A real @@ -37,8 +38,10 @@ #: Custom stream event name the Angular panel listens for. MEMORY_EVENT = "deep_agents.memory" +# endregion +# region visibility-middleware class MemoryVisibilityMiddleware(AgentMiddleware): """Republish `memory_contents` as a `custom` stream event. @@ -59,7 +62,7 @@ def _emit(self, state: dict[str, Any]) -> None: writer = get_stream_writer() except (RuntimeError, KeyError): # No streaming context. The value is still on the checkpoint, which - # is what the client's getState fallback reads. + # is what the client's history hydration reads. return writer({"name": MEMORY_EVENT, "data": {"memory_contents": contents}}) @@ -70,8 +73,10 @@ def after_model(self, state: dict[str, Any], runtime: Any) -> None: # noqa: ANN def after_agent(self, state: dict[str, Any], runtime: Any) -> None: # noqa: ANN401, ARG002 self._emit(state) return None +# endregion +# region memory-agent def build_memory_agent(): """Build the memory agent. @@ -87,6 +92,7 @@ def build_memory_agent(): memory=[MEMORY_FILE], middleware=[MemoryVisibilityMiddleware()], ) +# endregion graph = build_memory_agent() diff --git a/cockpit/deep-agents/planning/angular/src/app/planning.component.ts b/cockpit/deep-agents/planning/angular/src/app/planning.component.ts index 82cd0dfdf..fbf463ce5 100644 --- a/cockpit/deep-agents/planning/angular/src/app/planning.component.ts +++ b/cockpit/deep-agents/planning/angular/src/app/planning.component.ts @@ -3,6 +3,7 @@ import { ChatComponent, ChatWelcomeSuggestionComponent } from '@threadplane/chat import { ExampleChatLayoutComponent } from '@threadplane/example-layouts'; import { injectAgent } from '@threadplane/langgraph'; +// #region todo-shape /** * One entry of the `todos` list written by the `write_todos` tool. * @@ -15,6 +16,7 @@ interface Todo { } const TODO_STATUSES: ReadonlyArray = ['pending', 'in_progress', 'completed']; +// #endregion const SUGGESTIONS = [ // value matches cockpit/deep-agents/planning/angular/e2e/da-planning.spec.ts PROMPT. @@ -56,6 +58,7 @@ const SUGGESTIONS = [ }
+

Plan

@if (todos().length === 0) { @@ -84,6 +87,7 @@ const SUGGESTIONS = [
} + `, styles: [ @@ -167,6 +171,7 @@ export class PlanningComponent { protected readonly suggestions = SUGGESTIONS; + // #region todos-signal /** Live projection of `state.todos`, normalized against unknown statuses. */ protected readonly todos = computed(() => { const todos = (this.agent.value() as Record | undefined)?.['todos']; @@ -184,6 +189,7 @@ export class PlanningComponent { protected readonly completedCount = computed( () => this.todos().filter((todo) => todo.status === 'completed').length, ); + // #endregion protected send(text: string): void { void this.agent.submit({ message: text }); diff --git a/cockpit/deep-agents/planning/python/docs/guide.md b/cockpit/deep-agents/planning/python/docs/guide.md deleted file mode 100644 index 1cc48622b..000000000 --- a/cockpit/deep-agents/planning/python/docs/guide.md +++ /dev/null @@ -1,131 +0,0 @@ -# Planning with Deep Agents - - -Render the live todo list a `deepagents` agent keeps while it works. `TodoListMiddleware` -gives the model a `write_todos` tool and puts a `todos` array on the graph state; the Angular -panel is a `computed()` projection of that array, so rows move from pending to in progress to -completed as the agent works, and the list changes shape when the agent revises its plan. - - - -Add a live plan panel beside the `` component. Read `todos` off `injectAgent().value()`, -where each entry is `{ content, status }` with status one of `pending`, `in_progress`, or -`completed`, and render one row per todo with a status icon. - - - -This guide assumes `provideAgent()` is already configured. If it is not, work through the -[LangGraph quickstart](/docs/langgraph/getting-started/quickstart) first — every Deep Agents -capability uses the same provider, the same `injectAgent()` call, and the same `` -composition. - - - - - -`create_deep_agent` assembles a LangGraph agent from middleware. `TodoListMiddleware` is the -one that matters here: it registers the `write_todos` tool and declares the `todos` key on the -state schema. - -```python -# src/graph.py -from deepagents import create_deep_agent -from langchain.agents.middleware import TodoListMiddleware -from langchain_openai import ChatOpenAI - -graph = create_deep_agent( - model=ChatOpenAI(model="gpt-4.1", temperature=0), - tools=[lookup_field_elevation, lookup_runway_length, lookup_weather], - system_prompt=(PROMPTS_DIR / "planning.md").read_text(), - middleware=[TodoListMiddleware()], -) -``` - -The middleware is listed explicitly rather than left to the `create_deep_agent` defaults, so -the source says which component owns `todos`. - - - - -`TodoListMiddleware` supplies the model with a tool, not with a policy. Without instruction the -model will happily fan out six parallel lookups and never write a todo. The system prompt is -what makes the plan visible: - -```markdown -Your first action on any request is a call to `write_todos`. Do not call a -lookup tool before the todo list exists. Write one todo per step. - -Mark exactly one todo `in_progress` before you start it and mark it `completed` -the moment it is done. Call `write_todos` again for each transition. -``` - -Each `write_todos` call replaces the entire list. There is no partial update, so the panel never -has to reconcile anything. - - - - -`injectAgent().value()` is the latest graph state. Derive the rows with `computed()` and -normalize the status, because a state key is not a typed contract: - -```typescript -// planning.component.ts -interface Todo { - content: string; - status: 'pending' | 'in_progress' | 'completed'; -} - -protected readonly agent = injectAgent(); - -protected readonly todos = computed(() => { - const todos = (this.agent.value() as Record | undefined)?.['todos']; - if (!Array.isArray(todos)) return []; - return todos.map((todo) => { - const entry = todo as Record; - const status = entry['status'] as Todo['status']; - return { - content: String(entry['content'] ?? ''), - status: TODO_STATUSES.includes(status) ? status : 'pending', - }; - }); -}); -``` - - - - -A todo carries no identifier, so track by index rather than by content — the content of a row -can change when the agent rewrites a step. - -```html - - -
-

Plan

- @if (todos().length === 0) { -

No plan yet

- } - @for (todo of todos(); track $index) { -
- {{ todo.content }} -
- } -
-
-``` - -Styling off `[attr.data-status]` keeps the status mapping in CSS instead of spreading a second -copy of the enum through the template. - -
-
- - -The shape of a todo in `deepagents` 0.7.11 is exactly `{ content, status }`. There is no `id` -and no separate present-tense label, so do not build a panel that depends on one. - - - -- [Deep Agents Subagents](/deep-agents/core-capabilities/subagents/overview/python) — the same agent delegating work to child agents -- [Deep Agents Filesystem](/deep-agents/core-capabilities/filesystem/overview/python) — the file tree the agent writes into while it works - diff --git a/cockpit/deep-agents/planning/python/src/graph.py b/cockpit/deep-agents/planning/python/src/graph.py index 38c0a48cd..8680cc7ab 100644 --- a/cockpit/deep-agents/planning/python/src/graph.py +++ b/cockpit/deep-agents/planning/python/src/graph.py @@ -48,6 +48,7 @@ } +# region lookup-tools @tool def lookup_field_elevation(airport: str) -> str: """Return the field elevation in feet for a four-letter ICAO airport code.""" @@ -75,12 +76,15 @@ def lookup_weather(airport: str) -> str: return f"{airport.upper()}: {conditions}" +# endregion + + +# region deep-agent def build_planning_agent(): """Build the planning agent. - `TodoListMiddleware` is passed explicitly rather than relying on the - `create_deep_agent` default set so the capability shows exactly which - middleware puts `todos` on the state. + `TodoListMiddleware` is passed explicitly; `create_deep_agent` does not + install it on its own. """ return create_deep_agent( model=ChatOpenAI(model="gpt-4.1", temperature=0), @@ -88,6 +92,7 @@ def build_planning_agent(): system_prompt=(PROMPTS_DIR / "planning.md").read_text(), middleware=[TodoListMiddleware()], ) + # endregion graph = build_planning_agent() diff --git a/cockpit/deep-agents/skills/angular/src/app/skills.component.ts b/cockpit/deep-agents/skills/angular/src/app/skills.component.ts index ae2734aa6..76095dfa1 100644 --- a/cockpit/deep-agents/skills/angular/src/app/skills.component.ts +++ b/cockpit/deep-agents/skills/angular/src/app/skills.component.ts @@ -61,6 +61,7 @@ const SUGGESTIONS = [ }
+

Skill Index

@@ -92,6 +93,7 @@ const SUGGESTIONS = [ read on demand.

+ `, styles: [ @@ -180,6 +182,7 @@ export class SkillsComponent { protected readonly suggestions = SUGGESTIONS; + // #region skills-sources private readonly liveSkills = computed[] | null>(() => { for (const event of [...this.agent.customEvents()].reverse()) { if (event.name !== SKILLS_EVENT) continue; @@ -206,7 +209,9 @@ export class SkillsComponent { if (this.liveSkills()) return 'live'; return this.settledSkills() ? 'checkpoint' : 'none'; }); + // #endregion + // #region opened-paths /** Absolute paths the agent has read with `read_file` during this run. */ private readonly openedPaths = computed(() => { const paths: string[] = []; @@ -217,7 +222,9 @@ export class SkillsComponent { } return paths; }); + // #endregion + // #region skills-projection protected readonly skills = computed(() => { const source = this.liveSkills() ?? this.settledSkills() ?? []; const opened = this.openedPaths(); @@ -233,6 +240,7 @@ export class SkillsComponent { }; }); }); + // #endregion protected send(text: string): void { void this.agent.submit({ message: text }); diff --git a/cockpit/deep-agents/skills/python/docs/guide.md b/cockpit/deep-agents/skills/python/docs/guide.md deleted file mode 100644 index d948bf422..000000000 --- a/cockpit/deep-agents/skills/python/docs/guide.md +++ /dev/null @@ -1,128 +0,0 @@ -# Skills with Deep Agents - - -Give an agent procedures it loads on demand. A skill is a folder with a `SKILL.md`; -`SkillsMiddleware` puts only the frontmatter — a name and a description — in the system prompt -and leaves the body on the filesystem until a request matches. Rendering both halves is what -makes progressive disclosure visible rather than theoretical. - - - -Add a skill-index panel beside the `` component. Read `skills_metadata` from -`agent.customEvents()`, and mark each skill as opened when `agent.toolCalls()` shows a -`read_file` under that skill's directory. - - - -This guide assumes `provideAgent()` is already configured. If it is not, work through the -[LangGraph quickstart](/docs/langgraph/getting-started/quickstart) first. - - - - - -The frontmatter is the part the model sees first, so the `description` is doing the routing. -Write it as a matching rule, not as a summary: - -```markdown ---- -name: runway-analysis -description: Decide whether a runway is long enough for a given aircraft at a given field elevation. Use when the user asks about runway suitability, takeoff or landing distance, or operating out of a high-elevation field. -license: MIT ---- - -# Runway Analysis - -## Procedure - -1. Get the field elevation and the longest runway length. -2. Read `/skills/runway-analysis/reference/margins.md` for the margin table. - Do not work from memory — the table is the authority. -3. Compare, then state the verdict and the two numbers you compared. -``` - -Step 2 is the second stage of the disclosure. The reference file costs nothing until the -`SKILL.md` sends the agent to it. - - - - -`SkillsMiddleware` reads through a backend, and the choice of backend is a deployment decision. -`FilesystemBackend` documents itself as inappropriate for servers and HTTP APIs, which rules it -out for anything with a public URL. Seeding a process-local store and mounting it read-only -keeps the content in the repo without giving the agent the host: - -```python -def _seed_skills_store() -> InMemoryStore: - store = InMemoryStore() - backend = StoreBackend(namespace=lambda _runtime: SKILLS_NAMESPACE, store=store) - for path in sorted(SKILLS_DIR.rglob("*.md")): - backend.write(f"/{path.relative_to(SKILLS_DIR).as_posix()}", path.read_text()) - return store - -graph = create_deep_agent( - ..., - backend=CompositeBackend( - default=StateBackend(), - routes={"/skills/": StoreBackend(namespace=..., store=SKILLS_STORE)}, - ), - skills=["/skills/"], -) -``` - - -Seed the store at `/runway-analysis/SKILL.md`, not `/skills/runway-analysis/SKILL.md`. The -composite removes the matched prefix before delegating and re-adds it to the result, so a store -seeded with the prefix surfaces to the agent as `/skills/skills/runway-analysis/...` and the -skill scan finds nothing. - - - - - -The model has the names and descriptions and nothing else, so the prompt has to say what to do -with them: - -```markdown -Your procedures are not in this prompt — they are skills under `/skills/`. -When a request matches a skill, read its `SKILL.md` before you start, and -follow the procedure it gives you. If the `SKILL.md` points at another file, -read that too — the numbers in a reference file are the authority, and your -recollection is not. -``` - - - - -`skills_metadata` is annotated `PrivateStateAttr`, so it is absent from the `values` stream and -needs the same custom-event shim as the memory capability. What the agent actually opened needs -no shim at all — `read_file` is an ordinary tool call: - -```typescript -private readonly openedPaths = computed(() => { - const paths: string[] = []; - for (const call of this.agent.toolCalls()) { - if (call.name !== 'read_file') continue; - const path = (call.args as Record | undefined)?.['file_path']; - if (typeof path === 'string' && !paths.includes(path)) paths.push(path); - } - return paths; -}); -``` - -Match those against each skill's directory and the panel shows exactly what progressive -disclosure bought: one skill opened, the rest still on disk. - - - - - -The strongest test of a skills setup is the skill that does NOT get opened. If every skill's -files are read on every request, the index is not routing anything and the descriptions need -work. - - - -- [Deep Agents Memory](/deep-agents/core-capabilities/memory/overview/python) — the same private-state visibility problem, for `memory_contents` -- [Deep Agents Filesystem](/deep-agents/core-capabilities/filesystem/overview/python) — the backends the skill mount is built from - diff --git a/cockpit/deep-agents/skills/python/src/graph.py b/cockpit/deep-agents/skills/python/src/graph.py index d76740dec..1542f307c 100644 --- a/cockpit/deep-agents/skills/python/src/graph.py +++ b/cockpit/deep-agents/skills/python/src/graph.py @@ -32,6 +32,7 @@ from langgraph.config import get_stream_writer from langgraph.store.memory import InMemoryStore +# region skills-constants PROMPTS_DIR = Path(__file__).parent.parent / "prompts" SKILLS_DIR = Path(__file__).parent.parent / "skills" @@ -42,6 +43,7 @@ #: Custom stream event name the Angular panel listens for. SKILLS_EVENT = "deep_agents.skills" +# endregion FIELD_ELEVATION_FT = { "KSFO": 13, @@ -98,6 +100,7 @@ def lookup_weather(airport: str) -> str: return f"{airport.upper()}: {conditions}" +# region seed-store def _seed_skills_store() -> InMemoryStore: """Load the bundled skill folders into a process-local store. @@ -117,8 +120,10 @@ def _seed_skills_store() -> InMemoryStore: SKILLS_STORE = _seed_skills_store() +# endregion +# region visibility-middleware class SkillsVisibilityMiddleware(AgentMiddleware): """Republish `skills_metadata` as a `custom` stream event. @@ -158,6 +163,10 @@ def after_agent(self, state: dict[str, Any], runtime: Any) -> None: # noqa: ANN return None +# endregion + + +# region skills-agent def build_skills_agent(): """Build the skills agent. @@ -183,4 +192,7 @@ def build_skills_agent(): ) +# endregion + + graph = build_skills_agent() diff --git a/cockpit/deep-agents/subagents/angular/src/app/subagents.component.ts b/cockpit/deep-agents/subagents/angular/src/app/subagents.component.ts index e63521318..414862553 100644 --- a/cockpit/deep-agents/subagents/angular/src/app/subagents.component.ts +++ b/cockpit/deep-agents/subagents/angular/src/app/subagents.component.ts @@ -20,11 +20,12 @@ const SUGGESTIONS = [ /** * SubagentsComponent shows real child agents, not a tool-call log. * - * `SubAgentMiddleware` runs each `task` dispatch as its own graph in a - * `tools:` namespace, so a child's tokens arrive tagged with which - * dispatch produced them. Attribution is therefore structural: the tracker - * matches namespaces, and does not have to guess from message ordering. That - * is what makes parallel fan-out render correctly — four children streaming at + * `SubAgentMiddleware` runs each `task` dispatch as its own graph in its own + * namespace, and seeds it with the dispatch `description`. The tracker + * registers the dispatch from the `task` call and attributes the child stream + * by matching that description — exact match first, then containment either + * way, then a single remaining candidate. Descriptions that name their airport + * are what make parallel fan-out render correctly: four children streaming at * once land in four separate cards rather than interleaving into one. * * The cards are rendered inline by the `` composition and persist after @@ -49,6 +50,7 @@ const SUGGESTIONS = [ } +

Dispatches

@@ -60,6 +62,7 @@ const SUGGESTIONS = [

  • weather-analyst — conditions and operational impact
  • + `, styles: [ @@ -114,6 +117,7 @@ export class SubagentsComponent { protected readonly suggestions = SUGGESTIONS; + // #region dispatch-signals private readonly dispatches = computed(() => [...this.agent.subagents().values()]); protected readonly dispatchCount = computed(() => this.dispatches().length); @@ -121,6 +125,7 @@ export class SubagentsComponent { protected readonly runningCount = computed( () => this.dispatches().filter((subagent) => subagent.status() === 'running').length, ); + // #endregion protected send(text: string): void { void this.agent.submit({ message: text }); diff --git a/cockpit/deep-agents/subagents/python/docs/guide.md b/cockpit/deep-agents/subagents/python/docs/guide.md deleted file mode 100644 index b2a438db3..000000000 --- a/cockpit/deep-agents/subagents/python/docs/guide.md +++ /dev/null @@ -1,111 +0,0 @@ -# Subagents with Deep Agents - - -Render real child agents. `SubAgentMiddleware` dispatches through a single `task` tool, and -each dispatch runs as its own graph in a `tools:` namespace — so several children -streaming at the same time stay in separate cards. `task` is the SubagentTracker's default -dispatch-tool name, so the Angular side needs no configuration for this to work. - - - -Render subagent dispatches beside the `` component: `task` tool calls already register -as subagents, so read `agent.subagents()` for a live dispatch and running count. - - - -This guide assumes `provideAgent()` is already configured. If it is not, work through the -[LangGraph quickstart](/docs/langgraph/getting-started/quickstart) first. - - - - - -A `SubAgent` is a name, a description the orchestrator reads when choosing, a system prompt, -and the tools that child may use. Giving the orchestrator no lookup tools of its own is what -forces delegation: - -```python -from deepagents import SubAgent, create_deep_agent - -FIELD_RESEARCHER: SubAgent = { - "name": "field-researcher", - "description": "Gathers field elevation and runway length for one airport.", - "system_prompt": "You research airport field data for a dispatch desk. ...", - "tools": [lookup_field_elevation, lookup_runway_length], -} - -graph = create_deep_agent( - model=ChatOpenAI(model="gpt-4.1", temperature=0), - system_prompt=(PROMPTS_DIR / "subagents.md").read_text(), - subagents=[FIELD_RESEARCHER, WEATHER_ANALYST], -) -``` - -Passing `subagents` is what installs `SubAgentMiddleware` and, with it, the `task` tool. - - - - -The model will serialize dispatches unless told not to. One line in the orchestrator prompt -changes the shape of the run: - -```markdown -When a request covers more than one airport or more than one kind of data, -issue every dispatch you need **in a single turn** so the specialists work in -parallel. Do not wait for one to report before sending the next. -``` - - - - -`task` is an ordinary tool call on the wire. What makes it render as a child agent is that the -SubagentTracker recognizes the name — and `task` is its default, so a `deepagents` graph needs -no client configuration at all. Naming it explicitly is worth doing anyway, as documentation: - -```typescript -provideAgent({ - apiUrl: environment.langGraphApiUrl, - assistantId: environment.streamingAssistantId, - // The default. Set it when your dispatch tool is named something else. - subagentToolNames: ['task'], -}); -``` - -The SubagentTracker registers the dispatch from the tool call — including the `subagent_type` -argument, which becomes the card's name — and then matches the child's `tools:` -namespace. Because the dispatch is registered before the child emits its first token, -attribution never depends on message ordering, which is exactly why concurrent children do not -bleed into each other. - - - - -The `` composition renders each dispatch as a `` in the conversation -and keeps it, collapsed, after completion. A separate active-only tray would duplicate that, so -the sidebar is better spent on something the cards do not show — how wide the fan-out went: - -```typescript -private readonly dispatches = computed(() => [...this.agent.subagents().values()]); - -protected readonly dispatchCount = computed(() => this.dispatches().length); - -protected readonly runningCount = computed( - () => this.dispatches().filter((subagent) => subagent.status() === 'running').length, -); -``` - -Note that `status` is itself a signal on the `Subagent` record, so it is called, not read. - - - - - -Give every dispatch a `description` that names its subject and says what you want back. It is -both the child's opening instruction and the label a reader sees on the card, so a vague -description costs twice. - - - -- [Deep Agents Planning](/deep-agents/core-capabilities/planning/overview/python) — the orchestrator's own todo list -- [Chat Subagents](/chat/core-capabilities/subagents/overview/python) — the card components on their own - diff --git a/cockpit/deep-agents/subagents/python/src/graph.py b/cockpit/deep-agents/subagents/python/src/graph.py index da34fce12..2ca7f8d25 100644 --- a/cockpit/deep-agents/subagents/python/src/graph.py +++ b/cockpit/deep-agents/subagents/python/src/graph.py @@ -5,10 +5,11 @@ `tools:` namespace, and the child is seeded with the orchestrator's `description` before its first token. -That ordering is what lets the frontend attribute child output structurally -rather than by guessing: the SubagentTracker registers the dispatch from the -`task` call and matches the child namespace exactly. On the Angular side the -only wiring needed is `subagentToolNames: ['task']`. +That ordering is what lets the frontend attribute child output: the +SubagentTracker registers the dispatch from the `task` call, then matches the +child stream by its description — exact match first, then containment either +way, then a single remaining candidate. On the Angular side the only wiring +needed is `subagentToolNames: ['task']`. Parallel fan-out works: two `task` calls in one turn produce two children with distinct namespaces, distinct transcripts, and no cross-wiring. @@ -50,6 +51,7 @@ } +# region lookup-tools @tool def lookup_field_elevation(airport: str) -> str: """Return the field elevation in feet for a four-letter ICAO airport code.""" @@ -77,6 +79,9 @@ def lookup_weather(airport: str) -> str: return f"{airport.upper()}: {conditions}" +# endregion + +# region specialists FIELD_RESEARCHER: SubAgent = { "name": "field-researcher", "description": "Gathers field elevation and runway length for one airport.", @@ -99,13 +104,16 @@ def lookup_weather(airport: str) -> str: ), "tools": [lookup_weather], } +# endregion +# region orchestrator def build_subagents_agent(): """Build the orchestrator. - Passing `subagents` is what installs `SubAgentMiddleware` and, with it, the - `task` tool. The orchestrator gets no lookup tools of its own so it has no + `SubAgentMiddleware` and the `task` tool ship by default through the + `general-purpose` subagent; passing `subagents` puts these specialists on + that tool. The orchestrator gets no lookup tools of its own so it has no way to answer without delegating. """ return create_deep_agent( @@ -116,3 +124,4 @@ def build_subagents_agent(): graph = build_subagents_agent() +# endregion