Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions apps/cockpit/src/components/cockpit-shell.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => ({
Expand Down Expand Up @@ -121,13 +123,16 @@ const renderShell = (runtimeUrl: string | null = null) =>
</ThemeProvider>
);

const renderShellFor = (slug: string[]) => {
const renderShellFor = (
slug: string[],
presentationOverrides: Partial<CockpitPageModel['presentation']> = {}
) => {
const pageModel = getCockpitPageModel(slug);
return render(
<ThemeProvider theme="light">
<CockpitShell
navigationTree={pageModel.navigationTree}
presentation={pageModel.presentation}
presentation={{ ...pageModel.presentation, ...presentationOverrides }}
entryTitle={pageModel.entry.title}
contentBundle={baseContentBundle}
/>
Expand Down Expand Up @@ -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',
Expand All @@ -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();
});
});
2 changes: 1 addition & 1 deletion apps/cockpit/src/lib/route-resolution.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
111 changes: 111 additions & 0 deletions apps/website/content/docs/deep-agents/capabilities/filesystem.mdx
Original file line number Diff line number Diff line change
@@ -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.

<Callout type="warning" title="Anchor the permission pattern">
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.
</Callout>

## 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<WorkspaceFile[]>(() => {
const raw = (this.agent.value() as Record<string, unknown> | undefined)?.['files'];
const entries = new Map<string, string>();
if (raw && typeof raw === 'object') {
for (const [path, contents] of Object.entries(raw as Record<string, unknown>)) {
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<string | null>(() => {
for (const interrupt of this.agent.langGraphInterrupts() ?? []) {
const value = (interrupt as { value?: unknown }).value as
| { action_requests?: Array<{ args?: Record<string, unknown> }> }
| 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.
124 changes: 124 additions & 0 deletions apps/website/content/docs/deep-agents/capabilities/memory.mdx
Original file line number Diff line number Diff line change
@@ -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.

<Callout type="warning" title="Scope the namespace in anything real">
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.
</Callout>

## 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<Record<string, string> | 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<string, string>;
}
return null;
});

private readonly settledMemory = computed<Record<string, string> | null>(() => {
const contents = (this.agent.value() as Record<string, unknown> | undefined)?.[
'memory_contents'
];
return contents && typeof contents === 'object'
? (contents as Record<string, string>)
: 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.

<Callout type="tip" title="Test the store, not 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 the component works, not the store.
</Callout>

## 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.
Loading
Loading