@saadnvd1/agent-os 0.3.482 → 0.3.483
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -7
- package/app/api/fresh-start/route.ts +3 -1
- package/components/Workspaces/FreshStartForm.tsx +21 -9
- package/lib/chat/compact-context.ts +55 -0
- package/lib/chat/driver.ts +4 -0
- package/lib/chat/drivers/claude-approvals.ts +32 -0
- package/lib/chat/drivers/claude.ts +4 -0
- package/lib/chat/refresh.ts +19 -6
- package/lib/chat/stale.ts +5 -4
- package/lib/chat/worker/host.ts +9 -1
- package/lib/chat/working-state.ts +111 -0
- package/lib/db/migrations.ts +13 -0
- package/lib/usage/turns.ts +11 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -323,11 +323,11 @@ The ring in the tab bar (in the composer on a phone) is how full the
|
|
|
323
323
|
conversation's context is, measured against the room before auto-compact:
|
|
324
324
|
amber past 60%, red past 85%. Tap it for the breakdown and **Compact now**.
|
|
325
325
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
turns AgentOS catches what it didn't: a chat over
|
|
326
|
+
Two thresholds (Settings → Workspaces → Compact; 0 turns one off). Mid-turn,
|
|
327
|
+
the agent compacts itself at 300k by default: AgentOS launches Claude Code
|
|
328
|
+
with `CLAUDE_CODE_AUTO_COMPACT_WINDOW` set to that size (its floor is 100k),
|
|
329
|
+
high enough that a long job isn't cut in half. Between
|
|
330
|
+
turns AgentOS catches what it didn't: a chat over its 150k threshold is compacted
|
|
331
331
|
before the next message, and so is one idle past its prompt cache (5 minutes
|
|
332
332
|
or an hour, read from the agent's own usage), whose next message would
|
|
333
333
|
re-read the whole conversation at the cache-write price. A chip above the
|
|
@@ -339,6 +339,13 @@ shows one quiet line with the context before and after. An orchestrator
|
|
|
339
339
|
isn't compacted (a summary would lose the fence around what it read from
|
|
340
340
|
tasks and CI): it starts a fresh conversation from its brief and the current
|
|
341
341
|
state at its fresh-start threshold, or earlier once idle past its cache.
|
|
342
|
+
What survives a compaction: the summary is asked to keep the task, scope
|
|
343
|
+
changes, every edited file by path, what's done, in progress and next, and
|
|
344
|
+
the decisions. Right after it, AgentOS hands back a note built from its own
|
|
345
|
+
records (a task's brief and scope changes, the files it edited, its todos,
|
|
346
|
+
the latest message and `git status`), which doesn't depend on what the
|
|
347
|
+
summary kept, and tells the agent not to re-read or rewrite whole files.
|
|
348
|
+
Terminal agents don't get the note yet.
|
|
342
349
|
On a desktop browser the screen button attaches a screenshot of a screen or
|
|
343
350
|
window you pick; on a phone, attach a screenshot as a photo.
|
|
344
351
|
|
|
@@ -349,8 +356,8 @@ turn ends. **Gate reviews** below it shows what the orchestrator's
|
|
|
349
356
|
independent reviewer and scope check cost, by PR: they run as `claude -p`
|
|
350
357
|
and leave no transcript, so each run's tokens, model and duration are read
|
|
351
358
|
from its JSON result into `check_runs`, and the review's verdict says them
|
|
352
|
-
too. Every compaction is kept in `chat_compactions` (who ran it,
|
|
353
|
-
context before and after), so cost per task can be compared by date.
|
|
359
|
+
too. Every compaction is kept in `chat_compactions` (who ran it, whether it
|
|
360
|
+
fired `mid_turn` or `between_turns`, and the context before and after), so cost per task can be compared by date.
|
|
354
361
|
|
|
355
362
|
**Esc** stops a running turn, as it does in Claude Code's terminal, unless a
|
|
356
363
|
menu, dialog or the command list is open (those close first). On a phone the
|
|
@@ -3,7 +3,8 @@ import { refreshSettings, setRefreshSettings } from "@/lib/chat/refresh";
|
|
|
3
3
|
|
|
4
4
|
// When a chat's conversation starts over (lib/chat/refresh): context
|
|
5
5
|
// tokens for orchestrators and for tasks, and at which chats and tasks
|
|
6
|
-
// compact (lib/chat/stale)
|
|
6
|
+
// compact: between turns (lib/chat/stale) and mid-turn, by the agent itself;
|
|
7
|
+
// 0 for never.
|
|
7
8
|
export async function GET() {
|
|
8
9
|
return NextResponse.json(refreshSettings());
|
|
9
10
|
}
|
|
@@ -16,6 +17,7 @@ export async function PUT(request: NextRequest) {
|
|
|
16
17
|
taskTokens: body.taskTokens,
|
|
17
18
|
// Its name before it also set the agents' own auto-compact.
|
|
18
19
|
compactTokens: body.compactTokens ?? body.staleCompactTokens,
|
|
20
|
+
autoCompactTokens: body.autoCompactTokens,
|
|
19
21
|
});
|
|
20
22
|
return NextResponse.json(refreshSettings());
|
|
21
23
|
} catch (error) {
|
|
@@ -19,7 +19,7 @@ export function FreshStartForm() {
|
|
|
19
19
|
return <p className="text-destructive text-sm">{error.message}</p>;
|
|
20
20
|
return (
|
|
21
21
|
<Form
|
|
22
|
-
key={`${data.orchestratorTokens}:${data.taskTokens}:${data.compactTokens}`}
|
|
22
|
+
key={`${data.orchestratorTokens}:${data.taskTokens}:${data.compactTokens}:${data.autoCompactTokens}`}
|
|
23
23
|
saved={data}
|
|
24
24
|
/>
|
|
25
25
|
);
|
|
@@ -30,12 +30,15 @@ function Form({ saved }: { saved: RefreshSettings }) {
|
|
|
30
30
|
const orchestrator = useThreshold(saved.orchestratorTokens, 200);
|
|
31
31
|
const task = useThreshold(saved.taskTokens, 200);
|
|
32
32
|
const compact = useThreshold(saved.compactTokens, 150);
|
|
33
|
-
const
|
|
33
|
+
const midTurn = useThreshold(saved.autoCompactTokens, 300);
|
|
34
|
+
const error =
|
|
35
|
+
orchestrator.error ?? task.error ?? compact.error ?? midTurn.error;
|
|
34
36
|
const dirty =
|
|
35
37
|
!error &&
|
|
36
38
|
(orchestrator.tokens !== saved.orchestratorTokens ||
|
|
37
39
|
task.tokens !== saved.taskTokens ||
|
|
38
|
-
compact.tokens !== saved.compactTokens
|
|
40
|
+
compact.tokens !== saved.compactTokens ||
|
|
41
|
+
midTurn.tokens !== saved.autoCompactTokens);
|
|
39
42
|
|
|
40
43
|
return (
|
|
41
44
|
<form
|
|
@@ -47,6 +50,7 @@ function Form({ saved }: { saved: RefreshSettings }) {
|
|
|
47
50
|
orchestratorTokens: orchestrator.tokens,
|
|
48
51
|
taskTokens: task.tokens,
|
|
49
52
|
compactTokens: compact.tokens,
|
|
53
|
+
autoCompactTokens: midTurn.tokens,
|
|
50
54
|
},
|
|
51
55
|
{
|
|
52
56
|
onSuccess: () => toast.success("Context settings saved"),
|
|
@@ -67,15 +71,23 @@ function Form({ saved }: { saved: RefreshSettings }) {
|
|
|
67
71
|
<ThresholdRow label="Tasks" field={task} />
|
|
68
72
|
<h3 className="pt-2 text-sm font-medium">Compact</h3>
|
|
69
73
|
<p className="text-muted-foreground text-sm">
|
|
70
|
-
Chats and tasks compact
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
+
Chats and tasks compact twice over. Mid-turn, Claude Code summarizes its
|
|
75
|
+
own context when a job runs long: set this high, since it happens while
|
|
76
|
+
the agent is holding its files. Between turns, nothing is in flight, so
|
|
77
|
+
AgentOS compacts a chat over the smaller size, or idle past its prompt
|
|
78
|
+
cache, before the next message; the composer offers to first. Either way
|
|
79
|
+
AgentOS hands the agent its task, scope changes, files and todos back.
|
|
80
|
+
Orchestrators start fresh instead.
|
|
74
81
|
</p>
|
|
75
82
|
<ThresholdRow
|
|
76
|
-
label="
|
|
83
|
+
label="Mid-turn"
|
|
84
|
+
field={midTurn}
|
|
85
|
+
switchLabel="Let the agent compact itself mid-turn"
|
|
86
|
+
/>
|
|
87
|
+
<ThresholdRow
|
|
88
|
+
label="Between turns"
|
|
77
89
|
field={compact}
|
|
78
|
-
switchLabel="Compact chats and tasks
|
|
90
|
+
switchLabel="Compact chats and tasks between turns"
|
|
79
91
|
/>
|
|
80
92
|
{error && <p className="text-destructive text-xs">{error}</p>}
|
|
81
93
|
<div className="flex justify-end">
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The note an agent gets back right after a compaction, gathered from
|
|
3
|
+
* AgentOS's records (lib/chat/working-state has the rules). From the
|
|
4
|
+
* database each time rather than tracked in memory, so it is right after a
|
|
5
|
+
* deploy restarted the worker too.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import { statusPorcelain } from "../git-exec";
|
|
10
|
+
import { db, type Session } from "../db";
|
|
11
|
+
import { amendmentsOf } from "../orchestrator/amendments";
|
|
12
|
+
import { lastItems } from "./store";
|
|
13
|
+
import { workingStateNote } from "./working-state";
|
|
14
|
+
|
|
15
|
+
async function gitStatus(cwd: string): Promise<string> {
|
|
16
|
+
try {
|
|
17
|
+
const out = await statusPorcelain(cwd, {
|
|
18
|
+
timeout: 3_000,
|
|
19
|
+
maxBuffer: 64 * 1024,
|
|
20
|
+
});
|
|
21
|
+
return out.split("\n").slice(0, 40).join("\n");
|
|
22
|
+
} catch {
|
|
23
|
+
return "";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Empty when the session is gone or there is nothing to say.
|
|
28
|
+
export async function compactContext(sessionId: string): Promise<string> {
|
|
29
|
+
const session = db
|
|
30
|
+
.prepare(
|
|
31
|
+
`SELECT working_directory, task_prompt, role FROM sessions WHERE id = ?`
|
|
32
|
+
)
|
|
33
|
+
.get(sessionId) as
|
|
34
|
+
| Pick<Session, "working_directory" | "task_prompt" | "role">
|
|
35
|
+
| undefined;
|
|
36
|
+
// An orchestrator starts fresh instead of compacting.
|
|
37
|
+
if (!session || session.role === "orchestrator") return "";
|
|
38
|
+
const tools = lastItems(sessionId, 400, "tool").filter(
|
|
39
|
+
(i) => i.kind === "tool"
|
|
40
|
+
);
|
|
41
|
+
const todos = lastItems(sessionId, 1, "todos").find(
|
|
42
|
+
(i) => i.kind === "todos"
|
|
43
|
+
);
|
|
44
|
+
const user = lastItems(sessionId, 1, "user").find((i) => i.kind === "user");
|
|
45
|
+
return workingStateNote({
|
|
46
|
+
taskPrompt: session.task_prompt,
|
|
47
|
+
amendments: amendmentsOf(sessionId).map((a) => a.text),
|
|
48
|
+
tools,
|
|
49
|
+
todos: todos?.todos,
|
|
50
|
+
requests: user ? [user.text] : [],
|
|
51
|
+
gitStatus: await gitStatus(
|
|
52
|
+
session.working_directory.replace(/^~/, os.homedir())
|
|
53
|
+
),
|
|
54
|
+
});
|
|
55
|
+
}
|
package/lib/chat/driver.ts
CHANGED
|
@@ -41,6 +41,10 @@ export interface ChatStartOptions {
|
|
|
41
41
|
// Checked before every tool call, whatever the permissions say: why it's
|
|
42
42
|
// refused, or null to let it go on to them.
|
|
43
43
|
toolGuard?: ToolGuard;
|
|
44
|
+
// The note handed back to the agent right after its context is compacted
|
|
45
|
+
// (lib/chat/compact-context), empty for none. Only Claude Code has a hook
|
|
46
|
+
// for it; other drivers ignore it.
|
|
47
|
+
compactContext?: () => Promise<string>;
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
export type ToolGuard = (
|
|
@@ -275,3 +275,35 @@ export function preToolUseHooks(
|
|
|
275
275
|
...(guard ? [{ hooks: [guardHook(guard)] }] : []),
|
|
276
276
|
];
|
|
277
277
|
}
|
|
278
|
+
|
|
279
|
+
// After a compaction Claude Code starts the session again with source
|
|
280
|
+
// "compact", and what a SessionStart hook returns as additionalContext is
|
|
281
|
+
// added to the context: the one way to hand the agent something back that
|
|
282
|
+
// doesn't depend on what its summary kept. A failure here never blocks it.
|
|
283
|
+
export function compactedHooks(
|
|
284
|
+
context: () => Promise<string>
|
|
285
|
+
): HookCallbackMatcher[] {
|
|
286
|
+
return [
|
|
287
|
+
{
|
|
288
|
+
matcher: "compact",
|
|
289
|
+
hooks: [
|
|
290
|
+
async () => {
|
|
291
|
+
try {
|
|
292
|
+
const additionalContext = await context();
|
|
293
|
+
return additionalContext
|
|
294
|
+
? {
|
|
295
|
+
hookSpecificOutput: {
|
|
296
|
+
hookEventName: "SessionStart",
|
|
297
|
+
additionalContext,
|
|
298
|
+
},
|
|
299
|
+
}
|
|
300
|
+
: {};
|
|
301
|
+
} catch (e) {
|
|
302
|
+
console.error("building the compaction note:", e);
|
|
303
|
+
return {};
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
],
|
|
307
|
+
},
|
|
308
|
+
];
|
|
309
|
+
}
|
|
@@ -10,6 +10,7 @@ import type { DriverEvent, FileSuggestion, McpServerView } from "../events";
|
|
|
10
10
|
import { InputQueue } from "../queue";
|
|
11
11
|
import {
|
|
12
12
|
Approvals,
|
|
13
|
+
compactedHooks,
|
|
13
14
|
isPlanFile,
|
|
14
15
|
preToolUseHooks,
|
|
15
16
|
sdkMode,
|
|
@@ -193,6 +194,9 @@ export const claudeDriver: ChatDriver = {
|
|
|
193
194
|
disallowedTools: options.disallowedTools,
|
|
194
195
|
hooks: {
|
|
195
196
|
PreToolUse: preToolUseHooks(approvals, options.toolGuard),
|
|
197
|
+
...(options.compactContext && {
|
|
198
|
+
SessionStart: compactedHooks(options.compactContext),
|
|
199
|
+
}),
|
|
196
200
|
},
|
|
197
201
|
settings: { attribution: NO_ATTRIBUTION },
|
|
198
202
|
enableFileCheckpointing: true,
|
package/lib/chat/refresh.ts
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* default: its state is in the database and its brief carries it
|
|
7
7
|
* (lib/orchestrator/digest). A task keeps its progress only in its
|
|
8
8
|
* conversation and its branch, so for tasks it's off unless switched on.
|
|
9
|
-
* App-wide, in Settings → Workspaces, with the
|
|
10
|
-
* tasks compact (
|
|
9
|
+
* App-wide, in Settings → Workspaces, with the two sizes at which chats and
|
|
10
|
+
* tasks compact: mid-turn (Claude Code itself, high so a job keeps its
|
|
11
|
+
* files) and between turns (lib/chat/stale, earlier, nothing in flight).
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
import { db } from "../db";
|
|
@@ -20,16 +21,22 @@ export const TASK_KEY = "chat.task_refresh_tokens";
|
|
|
20
21
|
export const COMPACT_KEY = "chat.stale_compact_tokens";
|
|
21
22
|
export const DEFAULT_ORCHESTRATOR_TOKENS = 200_000;
|
|
22
23
|
export const DEFAULT_COMPACT_TOKENS = 150_000;
|
|
24
|
+
// Claude Code's own mid-turn auto-compact window: high, because a summary
|
|
25
|
+
// mid-job costs the agent its files and its place.
|
|
26
|
+
export const AUTO_COMPACT_KEY = "chat.auto_compact_tokens";
|
|
27
|
+
export const DEFAULT_AUTO_COMPACT_TOKENS = 300_000;
|
|
23
28
|
export { MIN_REFRESH_TOKENS, MAX_REFRESH_TOKENS } from "./refresh-limits";
|
|
24
29
|
|
|
25
30
|
export interface RefreshSettings {
|
|
26
31
|
// Context tokens at which a conversation starts over; 0 is off.
|
|
27
32
|
orchestratorTokens: number;
|
|
28
33
|
taskTokens: number;
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
// idle past its cache) is compacted before the next message. 0 is off.
|
|
34
|
+
// Between turns: a chat over this size (or idle past its cache) is
|
|
35
|
+
// compacted before its next message. 0 is off.
|
|
32
36
|
compactTokens: number;
|
|
37
|
+
// Mid-turn: Claude Code's own auto-compact window for a chat's or task's
|
|
38
|
+
// agent. 0 is off.
|
|
39
|
+
autoCompactTokens: number;
|
|
33
40
|
}
|
|
34
41
|
|
|
35
42
|
function read(key: string, fallback: number): number {
|
|
@@ -45,6 +52,7 @@ export function refreshSettings(): RefreshSettings {
|
|
|
45
52
|
orchestratorTokens: read(ORCHESTRATOR_KEY, DEFAULT_ORCHESTRATOR_TOKENS),
|
|
46
53
|
taskTokens: read(TASK_KEY, 0),
|
|
47
54
|
compactTokens: read(COMPACT_KEY, DEFAULT_COMPACT_TOKENS),
|
|
55
|
+
autoCompactTokens: read(AUTO_COMPACT_KEY, DEFAULT_AUTO_COMPACT_TOKENS),
|
|
48
56
|
};
|
|
49
57
|
}
|
|
50
58
|
|
|
@@ -79,6 +87,11 @@ export function setRefreshSettings(input: Partial<RefreshSettings>): void {
|
|
|
79
87
|
checked.push([TASK_KEY, checkRefreshTokens(input.taskTokens)]);
|
|
80
88
|
if (input.compactTokens !== undefined)
|
|
81
89
|
checked.push([COMPACT_KEY, checkRefreshTokens(input.compactTokens)]);
|
|
90
|
+
if (input.autoCompactTokens !== undefined)
|
|
91
|
+
checked.push([
|
|
92
|
+
AUTO_COMPACT_KEY,
|
|
93
|
+
checkRefreshTokens(input.autoCompactTokens),
|
|
94
|
+
]);
|
|
82
95
|
db.transaction(() => {
|
|
83
96
|
for (const [key, n] of checked) put.run(key, String(n));
|
|
84
97
|
})();
|
|
@@ -119,7 +132,7 @@ export function autoCompactEnv(sessionId: string): Record<string, string> {
|
|
|
119
132
|
.get(sessionId) as
|
|
120
133
|
| { role: string | null; model_provider: string | null }
|
|
121
134
|
| undefined;
|
|
122
|
-
const tokens = refreshSettings().
|
|
135
|
+
const tokens = refreshSettings().autoCompactTokens;
|
|
123
136
|
if (row?.role === "orchestrator" || tokens <= 0) return {};
|
|
124
137
|
const window = compactThreshold(
|
|
125
138
|
Math.max(AUTO_COMPACT_MIN, tokens),
|
package/lib/chat/stale.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Compacting between turns. A chat's agent compacts itself at
|
|
3
|
-
*
|
|
4
|
-
* lib/agents/launch). Between turns
|
|
5
|
-
* over the threshold is compacted before
|
|
2
|
+
* Compacting between turns. A chat's agent compacts itself mid-turn at its
|
|
3
|
+
* own, higher window (Claude Code's auto-compact, set at launch by
|
|
4
|
+
* lib/agents/launch). Between turns nothing is in flight, so AgentOS
|
|
5
|
+
* compacts earlier: a chat over the compact threshold is compacted before
|
|
6
|
+
* the next message, and so is one
|
|
6
7
|
* whose prompt cache has expired, since that message would re-read the
|
|
7
8
|
* whole history at the cache-write price (one summary turn, then a small
|
|
8
9
|
* cache write instead of a huge one). The one check every send path uses:
|
package/lib/chat/worker/host.ts
CHANGED
|
@@ -41,6 +41,8 @@ import {
|
|
|
41
41
|
} from "../../artifacts/tools";
|
|
42
42
|
import type { RoleExtras } from "./extras";
|
|
43
43
|
import { compactThreshold, refreshSettings, refreshedNote } from "../refresh";
|
|
44
|
+
import { compactContext } from "../compact-context";
|
|
45
|
+
import { COMPACT_BRIEF, COMPACT_INSTRUCTIONS } from "../working-state";
|
|
44
46
|
import { compactionDue, staleLabel, type Staleness } from "../stale";
|
|
45
47
|
import type { WorkerCommand, WorkerEvent } from "./protocol";
|
|
46
48
|
|
|
@@ -164,6 +166,7 @@ export class ChatHost {
|
|
|
164
166
|
this.atRest = driver.atRest !== false;
|
|
165
167
|
const cwd = session.working_directory.replace(/^~/, os.homedir());
|
|
166
168
|
const env = { ...agentEnv(session.id), ...extras.env };
|
|
169
|
+
const orchestrator = session.role === "orchestrator";
|
|
167
170
|
// Every chat can show visuals, asked for like any tool under its access
|
|
168
171
|
// setting (they read files and reach the web). Not an orchestrator: it
|
|
169
172
|
// reads other sessions' text, and a browser would be a way out for it.
|
|
@@ -196,6 +199,7 @@ export class ChatHost {
|
|
|
196
199
|
sessionRunningBrief(session.id),
|
|
197
200
|
BUS_BRIEF,
|
|
198
201
|
visuals && VISUALS_BRIEF,
|
|
202
|
+
!orchestrator && COMPACT_BRIEF,
|
|
199
203
|
]
|
|
200
204
|
.filter(Boolean)
|
|
201
205
|
.join("\n\n"),
|
|
@@ -211,6 +215,10 @@ export class ChatHost {
|
|
|
211
215
|
disallowedTools: extras.disallowedTools,
|
|
212
216
|
permissionMode: extras.permissionMode,
|
|
213
217
|
toolGuard: extras.toolGuard,
|
|
218
|
+
// Not an orchestrator: it starts a fresh conversation instead.
|
|
219
|
+
compactContext: orchestrator
|
|
220
|
+
? undefined
|
|
221
|
+
: () => compactContext(session.id),
|
|
214
222
|
});
|
|
215
223
|
this.suggestion = session.chat_suggestion ?? null;
|
|
216
224
|
try {
|
|
@@ -725,7 +733,7 @@ export class ChatHost {
|
|
|
725
733
|
};
|
|
726
734
|
this.currentTurn = null;
|
|
727
735
|
this.stopping = null;
|
|
728
|
-
this.conversation.send(
|
|
736
|
+
this.conversation.send(`/compact ${COMPACT_INSTRUCTIONS}`);
|
|
729
737
|
this.setState("running");
|
|
730
738
|
console.error(
|
|
731
739
|
`${new Date().toISOString()} compacting ${this.session.id} before a message: ${stale.stale ? `idle ${Math.round(stale.idleMs / 60_000)}m, ` : ""}${stale.tokens} tokens`
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an agent needs to keep its place across a compaction. Claude Code's
|
|
3
|
+
* summary decides for itself what mattered, and a long job's task, its scope
|
|
4
|
+
* changes and the files it was editing are exactly what it dropped: the
|
|
5
|
+
* agent re-read them and then rewrote whole files to be sure. So the summary
|
|
6
|
+
* is told what to keep (COMPACT_INSTRUCTIONS), and right after it AgentOS
|
|
7
|
+
* hands back a note built from its own records (workingStateNote), which
|
|
8
|
+
* doesn't depend on what the summary kept.
|
|
9
|
+
*
|
|
10
|
+
* Pure, so the rules are tested without a database (lib/chat/compact-context
|
|
11
|
+
* gathers the inputs).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ChatItem } from "./events";
|
|
15
|
+
|
|
16
|
+
export const COMPACT_INSTRUCTIONS = [
|
|
17
|
+
"Keep, verbatim where short: the task or request you are working on and any changes to its scope.",
|
|
18
|
+
"List every file you created or edited, by absolute path, and what you changed in each.",
|
|
19
|
+
"State what is done, what is in progress, and the exact next step.",
|
|
20
|
+
"Keep the decisions made and why, and any commands or checks still to run.",
|
|
21
|
+
"After the summary, do not re-read whole files you were editing: search for the spot and edit it in place, and never rewrite a whole large file to change a part of it.",
|
|
22
|
+
].join(" ");
|
|
23
|
+
|
|
24
|
+
// In the system prompt, which the summary request reuses and a summary
|
|
25
|
+
// can't drop.
|
|
26
|
+
export const COMPACT_BRIEF = `If your context is compacted, the summary must: ${COMPACT_INSTRUCTIONS}`;
|
|
27
|
+
|
|
28
|
+
const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
|
|
29
|
+
const MAX_FILES = 20;
|
|
30
|
+
const MAX_PROMPT = 8_000;
|
|
31
|
+
const MAX_REQUEST = 1_500;
|
|
32
|
+
const MAX_NOTE = 12_000;
|
|
33
|
+
|
|
34
|
+
type Todo = Extract<ChatItem, { kind: "todos" }>["todos"][number];
|
|
35
|
+
|
|
36
|
+
export interface WorkingState {
|
|
37
|
+
// The task's own prompt, when it is one, and its scope changes (oldest
|
|
38
|
+
// first).
|
|
39
|
+
taskPrompt?: string | null;
|
|
40
|
+
amendments?: string[];
|
|
41
|
+
// Tool calls and the latest todos and user messages, oldest first.
|
|
42
|
+
tools?: Extract<ChatItem, { kind: "tool" }>[];
|
|
43
|
+
todos?: Todo[];
|
|
44
|
+
requests?: string[];
|
|
45
|
+
// `git status --short`, when the directory is a repository.
|
|
46
|
+
gitStatus?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const cut = (text: string, max: number) =>
|
|
50
|
+
text.length > max ? `${text.slice(0, max)}\n… (cut)` : text;
|
|
51
|
+
|
|
52
|
+
// The path a file-changing tool call is about, newest first, each once.
|
|
53
|
+
export function editedFiles(
|
|
54
|
+
tools: Extract<ChatItem, { kind: "tool" }>[]
|
|
55
|
+
): string[] {
|
|
56
|
+
const seen = new Set<string>();
|
|
57
|
+
for (const t of [...tools].reverse()) {
|
|
58
|
+
if (!EDIT_TOOLS.has(t.name) || t.status === "error") continue;
|
|
59
|
+
const input = (t.input ?? {}) as Record<string, unknown>;
|
|
60
|
+
const path = input.file_path ?? input.notebook_path ?? t.diff?.path;
|
|
61
|
+
if (typeof path === "string" && path) seen.add(path);
|
|
62
|
+
if (seen.size >= MAX_FILES) break;
|
|
63
|
+
}
|
|
64
|
+
return [...seen];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const MARK: Record<Todo["status"], string> = {
|
|
68
|
+
completed: "done",
|
|
69
|
+
in_progress: "in progress",
|
|
70
|
+
pending: "next",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export function workingStateNote(s: WorkingState): string {
|
|
74
|
+
const parts: string[] = [];
|
|
75
|
+
const prompt = s.taskPrompt?.trim();
|
|
76
|
+
if (prompt) parts.push(`## Your task\n${cut(prompt, MAX_PROMPT)}`);
|
|
77
|
+
const amendments = (s.amendments ?? []).filter((a) => a.trim());
|
|
78
|
+
if (amendments.length)
|
|
79
|
+
parts.push(
|
|
80
|
+
`## Scope changes since it started (oldest first; they change the task above)\n${amendments
|
|
81
|
+
.map((a, i) => `${i + 1}. ${cut(a.trim(), MAX_REQUEST)}`)
|
|
82
|
+
.join("\n")}`
|
|
83
|
+
);
|
|
84
|
+
const files = editedFiles(s.tools ?? []);
|
|
85
|
+
if (files.length)
|
|
86
|
+
parts.push(
|
|
87
|
+
`## Files you were editing (newest first)\n${files.map((f) => `- ${f}`).join("\n")}`
|
|
88
|
+
);
|
|
89
|
+
if (s.todos?.length)
|
|
90
|
+
parts.push(
|
|
91
|
+
`## Your todo list\n${s.todos.map((t) => `- [${MARK[t.status]}] ${t.text}`).join("\n")}`
|
|
92
|
+
);
|
|
93
|
+
const requests = (s.requests ?? [])
|
|
94
|
+
.map((r) => r.trim())
|
|
95
|
+
// The task's own prompt is already above.
|
|
96
|
+
.filter((r) => r && !(prompt && r.startsWith(prompt.slice(0, 200))));
|
|
97
|
+
if (requests.length)
|
|
98
|
+
parts.push(
|
|
99
|
+
`## Latest from the reader\n${requests.map((r) => cut(r, MAX_REQUEST)).join("\n\n")}`
|
|
100
|
+
);
|
|
101
|
+
if (s.gitStatus?.trim())
|
|
102
|
+
parts.push(`## git status\n${cut(s.gitStatus.trim(), 1_500)}`);
|
|
103
|
+
if (!parts.length) return "";
|
|
104
|
+
parts.push(
|
|
105
|
+
"This note is from AgentOS, rebuilt from its own records after the context was compacted; the summary has the rest. Carry on from the next step. Don't re-read whole files you were editing (search for the spot), and edit in place rather than rewriting a whole file."
|
|
106
|
+
);
|
|
107
|
+
return cut(
|
|
108
|
+
`(Your context was just compacted. Where you were:)\n\n${parts.join("\n\n")}`,
|
|
109
|
+
MAX_NOTE
|
|
110
|
+
);
|
|
111
|
+
}
|
package/lib/db/migrations.ts
CHANGED
|
@@ -1459,6 +1459,19 @@ const migrations: Migration[] = [
|
|
|
1459
1459
|
);
|
|
1460
1460
|
})(),
|
|
1461
1461
|
},
|
|
1462
|
+
{
|
|
1463
|
+
id: 64,
|
|
1464
|
+
name: "chat_compactions_phase",
|
|
1465
|
+
up: (db) => {
|
|
1466
|
+
// Whether the agent compacted itself mid-turn or AgentOS (or a typed
|
|
1467
|
+
// /compact) did between turns; null on rows from before.
|
|
1468
|
+
const columns = db
|
|
1469
|
+
.prepare(`PRAGMA table_info(chat_compactions)`)
|
|
1470
|
+
.all() as { name: string }[];
|
|
1471
|
+
if (!columns.some((c) => c.name === "phase"))
|
|
1472
|
+
db.exec(`ALTER TABLE chat_compactions ADD COLUMN phase TEXT`);
|
|
1473
|
+
},
|
|
1474
|
+
},
|
|
1462
1475
|
];
|
|
1463
1476
|
|
|
1464
1477
|
// `upTo`: stop after this id (tests that start from an older database).
|
package/lib/usage/turns.ts
CHANGED
|
@@ -91,10 +91,16 @@ export function recordTurn(
|
|
|
91
91
|
return totals;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
// When a compaction ran: "mid_turn" is the agent's own, at its auto-compact
|
|
95
|
+
// window, while it worked; the rest happen between turns.
|
|
96
|
+
export type CompactionPhase = "mid_turn" | "between_turns";
|
|
97
|
+
export const compactionPhase = (trigger: string): CompactionPhase =>
|
|
98
|
+
trigger === "auto" ? "mid_turn" : "between_turns";
|
|
99
|
+
|
|
94
100
|
// Records one compaction: who ran it ("auto" is the agent's own, at its
|
|
95
101
|
// auto-compact window; "threshold" and "stale" are AgentOS's before a
|
|
96
|
-
// message; "manual" is a /compact someone typed),
|
|
97
|
-
// and after when the agent said.
|
|
102
|
+
// message; "manual" is a /compact someone typed), which phase that was,
|
|
103
|
+
// and the context before and after when the agent said.
|
|
98
104
|
export function recordCompaction(
|
|
99
105
|
session: Pick<Session, "id" | "name" | "workspace_id">,
|
|
100
106
|
c: { trigger: string; before?: number; after?: number },
|
|
@@ -106,14 +112,15 @@ export function recordCompaction(
|
|
|
106
112
|
| { name: string; workspace_id: string | null }
|
|
107
113
|
| undefined;
|
|
108
114
|
db.prepare(
|
|
109
|
-
`INSERT INTO chat_compactions (session_id, session_name, workspace_id, at, trigger, before_tokens, after_tokens)
|
|
110
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
115
|
+
`INSERT INTO chat_compactions (session_id, session_name, workspace_id, at, trigger, phase, before_tokens, after_tokens)
|
|
116
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
111
117
|
).run(
|
|
112
118
|
session.id,
|
|
113
119
|
current?.name ?? session.name,
|
|
114
120
|
current?.workspace_id ?? session.workspace_id,
|
|
115
121
|
at,
|
|
116
122
|
c.trigger,
|
|
123
|
+
compactionPhase(c.trigger),
|
|
117
124
|
c.before ?? null,
|
|
118
125
|
c.after ?? null
|
|
119
126
|
);
|