diff --git a/apps/cockpit/src/components/cockpit-shell.spec.tsx b/apps/cockpit/src/components/cockpit-shell.spec.tsx index 4db88d3f0..6beac1d78 100644 --- a/apps/cockpit/src/components/cockpit-shell.spec.tsx +++ b/apps/cockpit/src/components/cockpit-shell.spec.tsx @@ -13,7 +13,9 @@ import { ThemeProvider, } from '@threadplane/ui-react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NO_COCKPIT_DOCS_LINK } from '@threadplane/cockpit-registry'; import { getCockpitPageModel } from '../lib/cockpit-page'; +import type { CockpitPageModel } from '../lib/cockpit-page'; import type { UseRuntimeControllerOptions } from '../lib/runtime/use-runtime-controller'; const operationalMocks = vi.hoisted(() => ({ @@ -121,13 +123,16 @@ const renderShell = (runtimeUrl: string | null = null) => ); -const renderShellFor = (slug: string[]) => { +const renderShellFor = ( + slug: string[], + presentationOverrides: Partial = {} +) => { const pageModel = getCockpitPageModel(slug); return render( @@ -745,9 +750,7 @@ describe('CockpitShell documentation link', () => { expect(link.getAttribute('rel')).toBe('noopener noreferrer'); }); - it('renders no link for a capability with no published docs page', () => { - // deep-agents carries the NO_COCKPIT_DOCS_LINK sentinel: the website has no - // deep-agents library yet, so there is nothing to link to. + it('links a deep-agents capability at the deep-agents docs library', () => { renderShellFor([ 'deep-agents', 'core-capabilities', @@ -756,6 +759,21 @@ describe('CockpitShell documentation link', () => { 'python', ]); + const link = screen.getByRole('link', { name: /read docs/i }); + expect(link.getAttribute('href')).toBe( + 'https://threadplane.ai/docs/deep-agents/capabilities/planning' + ); + }); + + it('renders no link for a capability with no published docs page', () => { + // Every mapped capability now points at a published page, so the sentinel + // branch is exercised through a presentation carrying it rather than + // through a table entry that happens to be blank today. + renderShellFor( + ['deep-agents', 'core-capabilities', 'planning', 'overview', 'python'], + { docsPath: NO_COCKPIT_DOCS_LINK } + ); + expect(screen.queryByRole('link', { name: /read docs/i })).toBeNull(); }); }); diff --git a/apps/cockpit/src/lib/route-resolution.spec.ts b/apps/cockpit/src/lib/route-resolution.spec.ts index a72962253..cb50db8ba 100644 --- a/apps/cockpit/src/lib/route-resolution.spec.ts +++ b/apps/cockpit/src/lib/route-resolution.spec.ts @@ -176,7 +176,7 @@ describe('getCapabilityPresentation', () => { expect(getCapabilityPresentation(docsEntry)).toMatchObject({ kind: 'docs-only', - docsPath: '', + docsPath: '/docs/deep-agents/getting-started/introduction', }); expect(getCapabilityPresentation(capabilityEntry)).toMatchObject({ kind: 'capability', diff --git a/apps/website/content/docs/deep-agents/capabilities/filesystem.mdx b/apps/website/content/docs/deep-agents/capabilities/filesystem.mdx new file mode 100644 index 000000000..13270f994 --- /dev/null +++ b/apps/website/content/docs/deep-agents/capabilities/filesystem.mdx @@ -0,0 +1,111 @@ +--- +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. +--- + +# 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"), + ], +) +``` + +`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 shows + +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 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. + +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. + +Approving the write lets the run continue and the file lands in the tree. Rejecting it returns the model to work without the file. + + +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. + + +## 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 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. + +The interrupt payload is `{ action_requests: [{ name, args }] }`, and for `write_file` the target path is `args.file_path`: + +```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; +}); +``` + +### The resume payload + +`deepagents` expects a structured decision, not a bare string and not a bare list: + +```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' }] } }); + } +} +``` + +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. + +## Next steps + +- [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. diff --git a/apps/website/content/docs/deep-agents/capabilities/memory.mdx b/apps/website/content/docs/deep-agents/capabilities/memory.mdx new file mode 100644 index 000000000..ffdc3bb10 --- /dev/null +++ b/apps/website/content/docs/deep-agents/capabilities/memory.mdx @@ -0,0 +1,124 @@ +--- +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. +--- + +# 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. + +The backend decides how long the memory lasts. + +```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 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 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. + + +## What the demo shows + +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. + +What the agent records is a matter of prompt, not code: + +```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 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 + +Here the framework constrains the answer, and the constraint is worth stating rather than working around quietly. + +`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: + +```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}}) + + 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; 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. + + +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. + + +## Next steps + +- [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. diff --git a/apps/website/content/docs/deep-agents/capabilities/planning.mdx b/apps/website/content/docs/deep-agents/capabilities/planning.mdx new file mode 100644 index 000000000..fe98b71b8 --- /dev/null +++ b/apps/website/content/docs/deep-agents/capabilities/planning.mdx @@ -0,0 +1,104 @@ +--- +title: Planning +description: TodoListMiddleware puts a todos array on the graph state, and an Angular panel renders the plan as the agent revises it mid-run. +--- + +# 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()], +) +``` + +`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. + +## 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: + +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. + +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: + +```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. +``` + +## 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', + }; + }); + }); +} +``` + +Two details in that projection are deliberate. + +**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. + +**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: + +```html +@for (todo of todos(); track $index) { +
+ {{ todo.content }} +
+} +``` + +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. + + +## Next steps + +- [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()`. diff --git a/apps/website/content/docs/deep-agents/capabilities/skills.mdx b/apps/website/content/docs/deep-agents/capabilities/skills.mdx new file mode 100644 index 000000000..8fbfcddb1 --- /dev/null +++ b/apps/website/content/docs/deep-agents/capabilities/skills.mdx @@ -0,0 +1,111 @@ +--- +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. +--- + +# 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. + +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. + +```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. +``` + +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. + +## What the demo shows + +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. + +The prompt has to say that the index is an index: + +```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. +``` + +## 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/"], +) +``` + +`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. + + +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 + +The panel has two halves, and they arrive by different routes. + +**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. + +```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. + +**What the agent opened needs no shim at all.** A skill body is read with `read_file`, which is an ordinary tool call: + +```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; +}); +``` + +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 + +- [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. diff --git a/apps/website/content/docs/deep-agents/capabilities/subagents.mdx b/apps/website/content/docs/deep-agents/capabilities/subagents.mdx new file mode 100644 index 000000000..54ec29086 --- /dev/null +++ b/apps/website/content/docs/deep-agents/capabilities/subagents.mdx @@ -0,0 +1,86 @@ +--- +title: Subagents +description: SubAgentMiddleware dispatches child graphs through a task tool, which the Threadplane subagent tracker recognizes by default with no client configuration. +--- + +# 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. + +```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 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. + +## 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. + +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: + +```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. +``` + +## How it reaches the UI + +This is the capability that needs the least work on the client, because it needs none. + +`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: + +```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 + +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. + +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 + +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: + +```ts +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 rather than read. + + +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. + + +## Next steps + +- [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. diff --git a/apps/website/content/docs/deep-agents/getting-started/introduction.mdx b/apps/website/content/docs/deep-agents/getting-started/introduction.mdx new file mode 100644 index 000000000..5ec0811f8 --- /dev/null +++ b/apps/website/content/docs/deep-agents/getting-started/introduction.mdx @@ -0,0 +1,87 @@ +--- +title: Introduction +description: Render the Deep Agents middleware capabilities — planning, filesystem, subagents, memory, and skills — in an Angular UI over LangGraph. +--- + +# Introduction + +[Deep Agents](https://github.com/langchain-ai/deepagents) is LangChain's agent-harness library. It is not a protocol and not a runtime: it is a set of LangGraph middleware that assembles a long-horizon agent out of five capabilities, each of which puts something on the graph that a user interface can render. + +| Capability | Middleware | What it puts on the graph | +|---|---|---| +| [Planning](/docs/deep-agents/capabilities/planning) | `TodoListMiddleware` | A `todos` array the agent rewrites as it works. | +| [Filesystem](/docs/deep-agents/capabilities/filesystem) | `FilesystemMiddleware` | A `files` map, plus write approvals when a permission is set to interrupt. | +| [Subagents](/docs/deep-agents/capabilities/subagents) | `SubAgentMiddleware` | A `task` tool that dispatches real child graphs. | +| [Memory](/docs/deep-agents/capabilities/memory) | `MemoryMiddleware` | A memory file the agent maintains, held across threads. | +| [Skills](/docs/deep-agents/capabilities/skills) | `SkillsMiddleware` | A skill index loaded from `SKILL.md` frontmatter. | + +Every page in this section is written against the real `deepagents` package, version 0.7.11, running as a LangGraph graph. Nothing here is a reimplementation of the framework, and nothing here is a mock of it. + + +These pages document what a Deep Agents graph exposes and how an Angular application reads it. They are not a substitute for the upstream Deep Agents documentation, and Threadplane does not maintain the framework. + + +## The setup story, once + +A Deep Agents agent is a LangGraph graph. `create_deep_agent` returns a compiled graph, LangGraph Server serves it like any other, and the browser talks to it over the same protocol. There is no Deep Agents adapter, because none is needed: `@threadplane/langgraph` is the whole client wiring. + +That means the setup is identical for all five capabilities, and it is the setup already documented under the LangGraph adapter. Work through the [LangGraph quickstart](/docs/langgraph/getting-started/quickstart) once, and every page below assumes it. + +```ts +// app.config.ts +import { ApplicationConfig } from '@angular/core'; +import { provideAgent } from '@threadplane/langgraph'; +import { provideChat } from '@threadplane/chat'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideAgent({ + apiUrl: environment.langGraphApiUrl, + assistantId: environment.assistantId, + }), + provideChat({}), + ], +}; +``` + +```ts +import { Component, computed } from '@angular/core'; +import { ChatComponent } from '@threadplane/chat'; +import { injectAgent } from '@threadplane/langgraph'; + +@Component({ + selector: 'app-root', + imports: [ChatComponent], + template: ``, +}) +export class App { + protected readonly agent = injectAgent(); +} +``` + +What changes from capability to capability is not the provider and not the component. It is which part of the agent handle each panel reads: `value()` for state keys, `subagents()` for dispatches, `customEvents()` for keys the framework keeps private, `toolCalls()` for what the agent actually opened. + +## Three ways a capability reaches the browser + +Deep Agents does not publish all five capabilities the same way, and the difference decides how much work a panel is. + +**Public state keys stream on their own.** `todos` and `files` are ordinary keys on the graph state. LangGraph streams them in the `values` channel, `@threadplane/langgraph` projects the latest snapshot into `agent.value()`, and a panel is a `computed()` over that. No configuration, no custom events, no server-side shim. + +**Tool calls are already structured.** The subagent `task` tool and the filesystem `read_file` tool arrive as normal tool calls. The subagent tracker recognizes `task` by default, so child agents render as cards with no client configuration at all. + +**Private state keys do not stream.** `memory_contents` and `skills_metadata` are annotated `PrivateStateAttr` by their middleware. That annotation keeps them out of the `values` stream by design — they are context for the model, not transcript — and it means a panel bound to `agent.value()` shows nothing while the agent is working. Those keys are written to the checkpoint, so they arrive at settle, but a live panel needs the graph to announce them on a channel the client does receive. The memory and skills pages show the small middleware that does it, and say plainly that it is an application-side shim rather than a framework feature. + +That last row is a real constraint of the framework as it stands, not an oversight in the demos. It is stated on both pages it affects. + +## The demos + +Each capability page describes a standalone example that runs the real framework against a real model. The examples live under [`cockpit/deep-agents`](https://github.com/cacheplane/angular-agent-framework/tree/main/cockpit/deep-agents) and are hosted on the [Threadplane cockpit](https://cockpit.threadplane.ai), which shows the Angular source, the Python graph, and the system prompt beside the running demo. + +All five share one scenario — an aviation dispatch desk with a handful of airport lookup tools — so the difference between two pages is the capability under test and nothing else. + +## Further reading + +- [LangGraph adapter introduction](/docs/langgraph/getting-started/introduction) — the adapter every Deep Agents graph binds through. +- [Chat subagent card](/docs/chat/components/chat-subagent-card) — the component the `task` dispatches render into. +- [Chat interrupt panel](/docs/chat/components/chat-interrupt-panel) — the component a filesystem write approval renders into. +- [Persistence](/docs/langgraph/guides/persistence) — checkpoints and the store, which the memory capability depends on. diff --git a/apps/website/src/app/docs/page.tsx b/apps/website/src/app/docs/page.tsx index bad5598a7..9ae030669 100644 --- a/apps/website/src/app/docs/page.tsx +++ b/apps/website/src/app/docs/page.tsx @@ -226,6 +226,10 @@ export default function DocsLandingPage() { Agent runtimes → + {' '}Building on the Deep Agents framework?{' '} + + Deep Agents → +

diff --git a/apps/website/src/components/docs/LibraryMark.tsx b/apps/website/src/components/docs/LibraryMark.tsx index c80a47f37..daa7641ea 100644 --- a/apps/website/src/components/docs/LibraryMark.tsx +++ b/apps/website/src/components/docs/LibraryMark.tsx @@ -1,6 +1,6 @@ import type { LibraryId } from '../../lib/docs-config'; -type GlyphKey = 'chat' | 'middleware' | 'pulse' | 'layers'; +type GlyphKey = 'chat' | 'middleware' | 'pulse' | 'layers' | 'branch'; type MarkEntry = | { kind: 'logo'; src: string } @@ -15,6 +15,7 @@ const MARKS: Record = { middleware: { kind: 'glyph', glyph: 'middleware' }, telemetry: { kind: 'glyph', glyph: 'pulse' }, runtimes: { kind: 'glyph', glyph: 'layers' }, + 'deep-agents': { kind: 'glyph', glyph: 'branch' }, }; function ChatGlyph({ s }: { s: number }) { @@ -52,11 +53,23 @@ function LayersGlyph({ s }: { s: number }) { ); } +function BranchGlyph({ s }: { s: number }) { + return ( + + ); +} + const GLYPHS: Record React.JSX.Element> = { chat: ChatGlyph, middleware: MiddlewareGlyph, pulse: PulseGlyph, layers: LayersGlyph, + branch: BranchGlyph, }; interface Props { diff --git a/apps/website/src/lib/docs-config.ts b/apps/website/src/lib/docs-config.ts index eba538fb3..29af8890d 100644 --- a/apps/website/src/lib/docs-config.ts +++ b/apps/website/src/lib/docs-config.ts @@ -6,7 +6,8 @@ export type LibraryId = | 'a2ui' | 'middleware' | 'telemetry' - | 'runtimes'; + | 'runtimes' + | 'deep-agents'; export interface DocsPage { title: string; @@ -509,6 +510,37 @@ export const docsConfig: DocsLibrary[] = [ }, ], }, + { + id: 'deep-agents', + title: 'Deep Agents', + description: + 'Rendering the Deep Agents middleware capabilities — planning, filesystem, subagents, memory, and skills — in an Angular UI', + // The framework is a LangGraph agent harness, so these pages sit behind the + // LangGraph adapter rather than beside it. Reference material, not a pick. + group: 'library', + sections: [ + { + title: 'Getting Started', + id: 'getting-started', + color: 'blue', + pages: [ + { title: 'Introduction', slug: 'introduction', section: 'getting-started' }, + ], + }, + { + title: 'Capabilities', + id: 'capabilities', + color: 'blue', + pages: [ + { title: 'Planning', slug: 'planning', section: 'capabilities' }, + { title: 'Filesystem', slug: 'filesystem', section: 'capabilities' }, + { title: 'Subagents', slug: 'subagents', section: 'capabilities' }, + { title: 'Memory', slug: 'memory', section: 'capabilities' }, + { title: 'Skills', slug: 'skills', section: 'capabilities' }, + ], + }, + ], + }, ]; export function getLibraryConfig(libraryId: string): DocsLibrary | undefined { diff --git a/cockpit/deep-agents/filesystem/angular/src/index.ts b/cockpit/deep-agents/filesystem/angular/src/index.ts index 493125d36..5d0a9ceff 100644 --- a/cockpit/deep-agents/filesystem/angular/src/index.ts +++ b/cockpit/deep-agents/filesystem/angular/src/index.ts @@ -23,9 +23,7 @@ export const deepAgentsFilesystemAngularModule: CockpitCapabilityModule = { language: 'angular', }, title: 'Deep Agents Filesystem (Angular)', - // No `deep-agents` library exists on the website yet; the empty string is - // the "no published docs page" sentinel and renders no Docs link. - docsPath: '', + docsPath: '/docs/deep-agents/capabilities/filesystem', promptAssetPaths: [ 'cockpit/deep-agents/filesystem/angular/prompts/filesystem.md', ], diff --git a/cockpit/deep-agents/filesystem/python/src/index.ts b/cockpit/deep-agents/filesystem/python/src/index.ts index b479c4793..cdaa725de 100644 --- a/cockpit/deep-agents/filesystem/python/src/index.ts +++ b/cockpit/deep-agents/filesystem/python/src/index.ts @@ -27,9 +27,7 @@ export const deepAgentsFilesystemPythonModule: CockpitCapabilityModule = { language: 'python', }, title: 'Deep Agents Filesystem (Python)', - // No `deep-agents` library exists on the website yet; the empty string is - // the "no published docs page" sentinel and renders no Docs link. - docsPath: '', + docsPath: '/docs/deep-agents/capabilities/filesystem', promptAssetPaths: ['cockpit/deep-agents/filesystem/python/prompts/filesystem.md'], codeAssetPaths: [ 'cockpit/deep-agents/filesystem/angular/src/app/filesystem.component.ts', diff --git a/cockpit/deep-agents/memory/angular/src/index.ts b/cockpit/deep-agents/memory/angular/src/index.ts index 396b7d0b4..d2f9714dd 100644 --- a/cockpit/deep-agents/memory/angular/src/index.ts +++ b/cockpit/deep-agents/memory/angular/src/index.ts @@ -23,9 +23,7 @@ export const deepAgentsMemoryAngularModule: CockpitCapabilityModule = { language: 'angular', }, title: 'Deep Agents Memory (Angular)', - // No `deep-agents` library exists on the website yet; the empty string is - // the "no published docs page" sentinel and renders no Docs link. - docsPath: '', + docsPath: '/docs/deep-agents/capabilities/memory', promptAssetPaths: [ 'cockpit/deep-agents/memory/angular/prompts/memory.md', ], diff --git a/cockpit/deep-agents/memory/python/src/index.ts b/cockpit/deep-agents/memory/python/src/index.ts index 6bd7e1869..a40127df4 100644 --- a/cockpit/deep-agents/memory/python/src/index.ts +++ b/cockpit/deep-agents/memory/python/src/index.ts @@ -27,9 +27,7 @@ export const deepAgentsMemoryPythonModule: CockpitCapabilityModule = { language: 'python', }, title: 'Deep Agents Memory (Python)', - // No `deep-agents` library exists on the website yet; the empty string is - // the "no published docs page" sentinel and renders no Docs link. - docsPath: '', + docsPath: '/docs/deep-agents/capabilities/memory', promptAssetPaths: ['cockpit/deep-agents/memory/python/prompts/memory.md'], codeAssetPaths: [ 'cockpit/deep-agents/memory/angular/src/app/memory.component.ts', diff --git a/cockpit/deep-agents/planning/angular/src/index.ts b/cockpit/deep-agents/planning/angular/src/index.ts index 5f845f9d9..1c7eaf82f 100644 --- a/cockpit/deep-agents/planning/angular/src/index.ts +++ b/cockpit/deep-agents/planning/angular/src/index.ts @@ -23,9 +23,7 @@ export const deepAgentsPlanningAngularModule: CockpitCapabilityModule = { language: 'angular', }, title: 'Deep Agents Planning (Angular)', - // No `deep-agents` library exists on the website yet; the empty string is - // the "no published docs page" sentinel and renders no Docs link. - docsPath: '', + docsPath: '/docs/deep-agents/capabilities/planning', promptAssetPaths: [ 'cockpit/deep-agents/planning/angular/prompts/planning.md', ], diff --git a/cockpit/deep-agents/planning/python/src/index.ts b/cockpit/deep-agents/planning/python/src/index.ts index e9930f9b6..648fb9c0b 100644 --- a/cockpit/deep-agents/planning/python/src/index.ts +++ b/cockpit/deep-agents/planning/python/src/index.ts @@ -27,9 +27,7 @@ export const deepAgentsPlanningPythonModule: CockpitCapabilityModule = { language: 'python', }, title: 'Deep Agents Planning (Python)', - // No `deep-agents` library exists on the website yet; the empty string is - // the "no published docs page" sentinel and renders no Docs link. - docsPath: '', + docsPath: '/docs/deep-agents/capabilities/planning', promptAssetPaths: ['cockpit/deep-agents/planning/python/prompts/planning.md'], codeAssetPaths: [ 'cockpit/deep-agents/planning/angular/src/app/planning.component.ts', diff --git a/cockpit/deep-agents/skills/angular/src/index.ts b/cockpit/deep-agents/skills/angular/src/index.ts index 0498c15d9..11f94c480 100644 --- a/cockpit/deep-agents/skills/angular/src/index.ts +++ b/cockpit/deep-agents/skills/angular/src/index.ts @@ -23,9 +23,7 @@ export const deepAgentsSkillsAngularModule: CockpitCapabilityModule = { language: 'angular', }, title: 'Deep Agents Skills (Angular)', - // No `deep-agents` library exists on the website yet; the empty string is - // the "no published docs page" sentinel and renders no Docs link. - docsPath: '', + docsPath: '/docs/deep-agents/capabilities/skills', promptAssetPaths: [ 'cockpit/deep-agents/skills/angular/prompts/skills.md', ], diff --git a/cockpit/deep-agents/skills/python/src/index.ts b/cockpit/deep-agents/skills/python/src/index.ts index 77ad4530b..435c04cd6 100644 --- a/cockpit/deep-agents/skills/python/src/index.ts +++ b/cockpit/deep-agents/skills/python/src/index.ts @@ -27,9 +27,7 @@ export const deepAgentsSkillsPythonModule: CockpitCapabilityModule = { language: 'python', }, title: 'Deep Agents Skills (Python)', - // No `deep-agents` library exists on the website yet; the empty string is - // the "no published docs page" sentinel and renders no Docs link. - docsPath: '', + docsPath: '/docs/deep-agents/capabilities/skills', promptAssetPaths: ['cockpit/deep-agents/skills/python/prompts/skills.md'], codeAssetPaths: [ 'cockpit/deep-agents/skills/angular/src/app/skills.component.ts', diff --git a/cockpit/deep-agents/subagents/angular/src/index.ts b/cockpit/deep-agents/subagents/angular/src/index.ts index 85e182103..3ed76c5d2 100644 --- a/cockpit/deep-agents/subagents/angular/src/index.ts +++ b/cockpit/deep-agents/subagents/angular/src/index.ts @@ -23,9 +23,7 @@ export const deepAgentsSubagentsAngularModule: CockpitCapabilityModule = { language: 'angular', }, title: 'Deep Agents Subagents (Angular)', - // No `deep-agents` library exists on the website yet; the empty string is - // the "no published docs page" sentinel and renders no Docs link. - docsPath: '', + docsPath: '/docs/deep-agents/capabilities/subagents', promptAssetPaths: [ 'cockpit/deep-agents/subagents/angular/prompts/subagents.md', ], diff --git a/cockpit/deep-agents/subagents/python/src/index.ts b/cockpit/deep-agents/subagents/python/src/index.ts index a282ca31b..eb2343cbb 100644 --- a/cockpit/deep-agents/subagents/python/src/index.ts +++ b/cockpit/deep-agents/subagents/python/src/index.ts @@ -27,9 +27,7 @@ export const deepAgentsSubagentsPythonModule: CockpitCapabilityModule = { language: 'python', }, title: 'Deep Agents Subagents (Python)', - // No `deep-agents` library exists on the website yet; the empty string is - // the "no published docs page" sentinel and renders no Docs link. - docsPath: '', + docsPath: '/docs/deep-agents/capabilities/subagents', promptAssetPaths: ['cockpit/deep-agents/subagents/python/prompts/subagents.md'], codeAssetPaths: [ 'cockpit/deep-agents/subagents/angular/src/app/subagents.component.ts', diff --git a/libs/cockpit-registry/src/lib/docs-links.ts b/libs/cockpit-registry/src/lib/docs-links.ts index 7e8054cd1..0a0c86c10 100644 --- a/libs/cockpit-registry/src/lib/docs-links.ts +++ b/libs/cockpit-registry/src/lib/docs-links.ts @@ -34,13 +34,13 @@ export const NO_COCKPIT_DOCS_LINK = ''; * lanes of one cockpit demo point at the same page. */ export const COCKPIT_DOCS_LINKS: Readonly> = { - // deep-agents — no `deep-agents` library exists on the website yet. - 'deep-agents/getting-started/overview': NO_COCKPIT_DOCS_LINK, - 'deep-agents/core-capabilities/planning': NO_COCKPIT_DOCS_LINK, - 'deep-agents/core-capabilities/filesystem': NO_COCKPIT_DOCS_LINK, - 'deep-agents/core-capabilities/subagents': NO_COCKPIT_DOCS_LINK, - 'deep-agents/core-capabilities/memory': NO_COCKPIT_DOCS_LINK, - 'deep-agents/core-capabilities/skills': NO_COCKPIT_DOCS_LINK, + // deep-agents + 'deep-agents/getting-started/overview': '/docs/deep-agents/getting-started/introduction', + 'deep-agents/core-capabilities/planning': '/docs/deep-agents/capabilities/planning', + 'deep-agents/core-capabilities/filesystem': '/docs/deep-agents/capabilities/filesystem', + 'deep-agents/core-capabilities/subagents': '/docs/deep-agents/capabilities/subagents', + 'deep-agents/core-capabilities/memory': '/docs/deep-agents/capabilities/memory', + 'deep-agents/core-capabilities/skills': '/docs/deep-agents/capabilities/skills', // langgraph 'langgraph/getting-started/overview': '/docs/langgraph/getting-started/introduction', @@ -104,18 +104,12 @@ export const COCKPIT_DOCS_LINKS: Readonly> = { /** * The capabilities that deliberately carry `NO_COCKPIT_DOCS_LINK`. * - * Kept as an explicit list so the guard spec can assert that the only blank - * entries are these — a rename that accidentally blanks a real link fails - * instead of quietly dropping the "Docs" button from a page. + * Currently empty: every mapped capability points at a published page. The list + * stays because the guard spec asserts that the blanked entries are exactly + * these — so with an empty list, blanking anything at all fails, rather than + * quietly dropping the "Docs" button from a page. */ -export const COCKPIT_TOPICS_WITHOUT_DOCS: readonly string[] = [ - 'deep-agents/getting-started/overview', - 'deep-agents/core-capabilities/planning', - 'deep-agents/core-capabilities/filesystem', - 'deep-agents/core-capabilities/subagents', - 'deep-agents/core-capabilities/memory', - 'deep-agents/core-capabilities/skills', -]; +export const COCKPIT_TOPICS_WITHOUT_DOCS: readonly string[] = []; /** * Resolve the website documentation URL for a cockpit capability.