@saadnvd1/agent-os 0.3.464 → 0.3.465
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 +13 -0
- package/app/api/fresh-start/route.ts +5 -3
- package/components/Chat/ChatPanel.tsx +23 -1
- package/components/Chat/Messages.tsx +2 -3
- package/components/Chat/StaleChip.tsx +55 -0
- package/components/Workspaces/FreshStartForm.tsx +29 -6
- package/data/chat/useChat.ts +10 -2
- package/lib/bus/index.ts +3 -0
- package/lib/chat/client-send.ts +11 -3
- package/lib/chat/drivers/claude-mapper.ts +11 -1
- package/lib/chat/drivers/claude.ts +9 -1
- package/lib/chat/events.ts +14 -1
- package/lib/chat/refresh.ts +14 -2
- package/lib/chat/runner.ts +8 -1
- package/lib/chat/stale.ts +95 -0
- package/lib/chat/worker/host.ts +133 -4
- package/lib/chat/worker/protocol.ts +3 -0
- package/lib/orchestrator/deliver.ts +9 -1
- package/lib/schedules/start.ts +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -311,6 +311,19 @@ mode and starts it, **Keep planning** goes back to the composer.
|
|
|
311
311
|
The ring in the tab bar (in the composer on a phone) is how full the
|
|
312
312
|
conversation's context is, measured against the room before auto-compact:
|
|
313
313
|
amber past 60%, red past 85%. Tap it for the breakdown and **Compact now**.
|
|
314
|
+
|
|
315
|
+
A message to a chat idle past its prompt cache (5 minutes or an hour, read
|
|
316
|
+
from the agent's own usage) re-reads the whole conversation at the
|
|
317
|
+
cache-write price. When the context is also above a threshold (150k tokens by
|
|
318
|
+
default, Settings → Workspaces → Compact idle chats; off sets it to never), a
|
|
319
|
+
chip above the composer says "Idle 47m · 412k context · Compact first?": Send
|
|
320
|
+
compacts and then sends, **Send as-is** skips it for that one message.
|
|
321
|
+
Messages nobody is typing (the orchestrator's `send`, AgentOS events and
|
|
322
|
+
wakes, `aos send`, schedules) compact first on their own, only between turns,
|
|
323
|
+
and the chat shows one quiet line with the context before and after. An
|
|
324
|
+
orchestrator isn't compacted (a summary would lose the fence around what it
|
|
325
|
+
read from tasks and CI): it starts a fresh conversation from its brief and
|
|
326
|
+
the current state instead, as at its fresh-start threshold.
|
|
314
327
|
On a desktop browser the screen button attaches a screenshot of a screen or
|
|
315
328
|
window you pick; on a phone, attach a screenshot as a photo.
|
|
316
329
|
|
|
@@ -2,15 +2,17 @@ import { NextRequest, NextResponse } from "next/server";
|
|
|
2
2
|
import { refreshSettings, setRefreshSettings } from "@/lib/chat/refresh";
|
|
3
3
|
|
|
4
4
|
// When a chat's conversation starts over (lib/chat/refresh): context
|
|
5
|
-
// tokens for orchestrators and for tasks,
|
|
5
|
+
// tokens for orchestrators and for tasks, and above which a stale chat is
|
|
6
|
+
// compacted before a message (lib/chat/stale); 0 for never.
|
|
6
7
|
export async function GET() {
|
|
7
8
|
return NextResponse.json(refreshSettings());
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
export async function PUT(request: NextRequest) {
|
|
11
12
|
try {
|
|
12
|
-
const { orchestratorTokens, taskTokens } =
|
|
13
|
-
|
|
13
|
+
const { orchestratorTokens, taskTokens, staleCompactTokens } =
|
|
14
|
+
await request.json();
|
|
15
|
+
setRefreshSettings({ orchestratorTokens, taskTokens, staleCompactTokens });
|
|
14
16
|
return NextResponse.json(refreshSettings());
|
|
15
17
|
} catch (error) {
|
|
16
18
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -26,6 +26,9 @@ import { SubagentCard } from "./Subagent";
|
|
|
26
26
|
import { UndoDialog, UndoneBlock } from "./Undo";
|
|
27
27
|
import { Composer } from "./Composer";
|
|
28
28
|
import { Queue } from "./Queue";
|
|
29
|
+
import { StaleChip } from "./StaleChip";
|
|
30
|
+
import { composerCompacts } from "@/lib/chat/stale";
|
|
31
|
+
import { useFreshStartQuery } from "@/data/fresh-start";
|
|
29
32
|
import { QuoteButton } from "./QuoteButton";
|
|
30
33
|
import { ToolBodies, ToolGroup } from "./Tools";
|
|
31
34
|
import { RowState, RowStore } from "./rowState";
|
|
@@ -282,6 +285,12 @@ export function ChatPanel({
|
|
|
282
285
|
toEnd();
|
|
283
286
|
send(text);
|
|
284
287
|
};
|
|
288
|
+
// A stale chat compacts before the next message unless the reader picks
|
|
289
|
+
// "Send as-is", for that send only.
|
|
290
|
+
const staleCompactTokens = useFreshStartQuery().data?.staleCompactTokens ?? 0;
|
|
291
|
+
const [asIsFor, setAsIsFor] = useState<string | null>(null);
|
|
292
|
+
const asIs = asIsFor === sessionId;
|
|
293
|
+
const setAsIs = (on: boolean) => setAsIsFor(on ? sessionId : null);
|
|
285
294
|
|
|
286
295
|
// Shift+Tab switches plan mode from the composer, as in the terminal.
|
|
287
296
|
const shiftTab = useRef(togglePlan);
|
|
@@ -517,6 +526,13 @@ export function ChatPanel({
|
|
|
517
526
|
)
|
|
518
527
|
}
|
|
519
528
|
/>
|
|
529
|
+
<StaleChip
|
|
530
|
+
context={context}
|
|
531
|
+
state={state}
|
|
532
|
+
thresholdTokens={staleCompactTokens}
|
|
533
|
+
asIs={asIs}
|
|
534
|
+
onAsIs={setAsIs}
|
|
535
|
+
/>
|
|
520
536
|
<Composer
|
|
521
537
|
draftKey={sessionId}
|
|
522
538
|
suggestion={shownSuggestion}
|
|
@@ -529,7 +545,13 @@ export function ChatPanel({
|
|
|
529
545
|
placeholder={connected ? `Message ${sessionName}` : "Reconnecting…"}
|
|
530
546
|
onSend={(text, images) => {
|
|
531
547
|
toEnd();
|
|
532
|
-
|
|
548
|
+
const compact = composerCompacts(text, context, {
|
|
549
|
+
state,
|
|
550
|
+
thresholdTokens: staleCompactTokens,
|
|
551
|
+
asIs,
|
|
552
|
+
});
|
|
553
|
+
setAsIs(false);
|
|
554
|
+
send(text, images.length ? images : undefined, compact);
|
|
533
555
|
}}
|
|
534
556
|
onStop={interrupt}
|
|
535
557
|
commands={commands}
|
|
@@ -4,6 +4,7 @@ import { Check, ChevronRight, Circle, CircleDot, Sparkles } from "lucide-react";
|
|
|
4
4
|
import type { ChatItem } from "@/lib/chat/events";
|
|
5
5
|
import { cn } from "@/lib/utils";
|
|
6
6
|
import { leadingCommand } from "@/lib/chat/commands";
|
|
7
|
+
import { compactedLine } from "@/lib/chat/stale";
|
|
7
8
|
import { CopyButton } from "./CopyButton";
|
|
8
9
|
import { ImageThumb } from "./ImageThumb";
|
|
9
10
|
import { Markdown } from "./Markdown";
|
|
@@ -184,9 +185,7 @@ export function Compacted({ item }: { item: Of<"compacted"> }) {
|
|
|
184
185
|
return (
|
|
185
186
|
<div className="text-muted-foreground/60 flex items-center gap-3 font-mono text-[11px]">
|
|
186
187
|
<span className="bg-foreground/[0.06] h-px flex-1" />
|
|
187
|
-
{item
|
|
188
|
-
? "Context compacted automatically"
|
|
189
|
-
: "Context compacted"}
|
|
188
|
+
{compactedLine(item)}
|
|
190
189
|
<span className="bg-foreground/[0.06] h-px flex-1" />
|
|
191
190
|
</div>
|
|
192
191
|
);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Minimize2 } from "lucide-react";
|
|
4
|
+
import type { ChatContext, ChatState } from "@/lib/chat/events";
|
|
5
|
+
import { staleLabel, staleness } from "@/lib/chat/stale";
|
|
6
|
+
import { cn } from "@/lib/utils";
|
|
7
|
+
import { useNow } from "./useNow";
|
|
8
|
+
|
|
9
|
+
// Above the composer while the chat is stale (lib/chat/stale): its prompt
|
|
10
|
+
// cache has expired and its context is long, so the next message would
|
|
11
|
+
// re-read all of it at full price. Send compacts first unless the reader
|
|
12
|
+
// picks "Send as-is", for that one send only.
|
|
13
|
+
export function StaleChip({
|
|
14
|
+
context,
|
|
15
|
+
state,
|
|
16
|
+
thresholdTokens,
|
|
17
|
+
asIs,
|
|
18
|
+
onAsIs,
|
|
19
|
+
}: {
|
|
20
|
+
context: ChatContext | null;
|
|
21
|
+
state: ChatState;
|
|
22
|
+
thresholdTokens: number;
|
|
23
|
+
asIs: boolean;
|
|
24
|
+
onAsIs: (asIs: boolean) => void;
|
|
25
|
+
}) {
|
|
26
|
+
const watching = !!context?.cacheTtlMs && thresholdTokens > 0;
|
|
27
|
+
const now = useNow(watching);
|
|
28
|
+
const stale = staleness(context, { state, thresholdTokens, now });
|
|
29
|
+
if (!stale) return null;
|
|
30
|
+
return (
|
|
31
|
+
<div
|
|
32
|
+
role="status"
|
|
33
|
+
className="bg-card popover-surface mb-2 flex items-center gap-2 rounded-2xl py-1 pr-1 pl-3 text-xs"
|
|
34
|
+
>
|
|
35
|
+
<Minimize2 className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
|
|
36
|
+
<span className="text-muted-foreground min-w-0 flex-1 truncate">
|
|
37
|
+
{staleLabel(stale)} ·{" "}
|
|
38
|
+
<span className="text-foreground">
|
|
39
|
+
{asIs ? "Sending as-is" : "Compact first?"}
|
|
40
|
+
</span>
|
|
41
|
+
</span>
|
|
42
|
+
<button
|
|
43
|
+
type="button"
|
|
44
|
+
aria-pressed={asIs}
|
|
45
|
+
onClick={() => onAsIs(!asIs)}
|
|
46
|
+
className={cn(
|
|
47
|
+
"hover:bg-foreground/[0.06] min-h-11 shrink-0 rounded-xl px-3 font-medium md:min-h-8",
|
|
48
|
+
asIs && "text-primary"
|
|
49
|
+
)}
|
|
50
|
+
>
|
|
51
|
+
{asIs ? "Compact first" : "Send as-is"}
|
|
52
|
+
</button>
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -18,7 +18,10 @@ export function FreshStartForm() {
|
|
|
18
18
|
if (isError)
|
|
19
19
|
return <p className="text-destructive text-sm">{error.message}</p>;
|
|
20
20
|
return (
|
|
21
|
-
<Form
|
|
21
|
+
<Form
|
|
22
|
+
key={`${data.orchestratorTokens}:${data.taskTokens}:${data.staleCompactTokens}`}
|
|
23
|
+
saved={data}
|
|
24
|
+
/>
|
|
22
25
|
);
|
|
23
26
|
}
|
|
24
27
|
|
|
@@ -26,11 +29,13 @@ function Form({ saved }: { saved: RefreshSettings }) {
|
|
|
26
29
|
const update = useUpdateFreshStart();
|
|
27
30
|
const orchestrator = useThreshold(saved.orchestratorTokens, 200);
|
|
28
31
|
const task = useThreshold(saved.taskTokens, 200);
|
|
29
|
-
const
|
|
32
|
+
const stale = useThreshold(saved.staleCompactTokens, 150);
|
|
33
|
+
const error = orchestrator.error ?? task.error ?? stale.error;
|
|
30
34
|
const dirty =
|
|
31
35
|
!error &&
|
|
32
36
|
(orchestrator.tokens !== saved.orchestratorTokens ||
|
|
33
|
-
task.tokens !== saved.taskTokens
|
|
37
|
+
task.tokens !== saved.taskTokens ||
|
|
38
|
+
stale.tokens !== saved.staleCompactTokens);
|
|
34
39
|
|
|
35
40
|
return (
|
|
36
41
|
<form
|
|
@@ -38,9 +43,13 @@ function Form({ saved }: { saved: RefreshSettings }) {
|
|
|
38
43
|
e.preventDefault();
|
|
39
44
|
if (error) return;
|
|
40
45
|
update.mutate(
|
|
41
|
-
{ orchestratorTokens: orchestrator.tokens, taskTokens: task.tokens },
|
|
42
46
|
{
|
|
43
|
-
|
|
47
|
+
orchestratorTokens: orchestrator.tokens,
|
|
48
|
+
taskTokens: task.tokens,
|
|
49
|
+
staleCompactTokens: stale.tokens,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
onSuccess: () => toast.success("Context settings saved"),
|
|
44
53
|
onError: (err) => toast.error(err.message),
|
|
45
54
|
}
|
|
46
55
|
);
|
|
@@ -56,6 +65,18 @@ function Form({ saved }: { saved: RefreshSettings }) {
|
|
|
56
65
|
</p>
|
|
57
66
|
<ThresholdRow label="Orchestrators" field={orchestrator} />
|
|
58
67
|
<ThresholdRow label="Tasks" field={task} />
|
|
68
|
+
<h3 className="pt-2 text-sm font-medium">Compact idle chats</h3>
|
|
69
|
+
<p className="text-muted-foreground text-sm">
|
|
70
|
+
A message to a chat idle past its prompt cache re-reads the whole
|
|
71
|
+
conversation at full price. Above this size, messages from the
|
|
72
|
+
orchestrator, events and schedules compact it first; the composer offers
|
|
73
|
+
to.
|
|
74
|
+
</p>
|
|
75
|
+
<ThresholdRow
|
|
76
|
+
label="Idle chats"
|
|
77
|
+
field={stale}
|
|
78
|
+
switchLabel="Compact idle chats before a message"
|
|
79
|
+
/>
|
|
59
80
|
{error && <p className="text-destructive text-xs">{error}</p>}
|
|
60
81
|
<div className="flex justify-end">
|
|
61
82
|
<Button
|
|
@@ -90,9 +111,11 @@ function useThreshold(tokens: number, fallbackK: number) {
|
|
|
90
111
|
function ThresholdRow({
|
|
91
112
|
label,
|
|
92
113
|
field,
|
|
114
|
+
switchLabel,
|
|
93
115
|
}: {
|
|
94
116
|
label: string;
|
|
95
117
|
field: ReturnType<typeof useThreshold>;
|
|
118
|
+
switchLabel?: string;
|
|
96
119
|
}) {
|
|
97
120
|
return (
|
|
98
121
|
<div className="space-y-2">
|
|
@@ -101,7 +124,7 @@ function ThresholdRow({
|
|
|
101
124
|
<Switch
|
|
102
125
|
checked={field.on}
|
|
103
126
|
onCheckedChange={field.setOn}
|
|
104
|
-
aria-label={`Fresh starts for ${label.toLowerCase()}`}
|
|
127
|
+
aria-label={switchLabel ?? `Fresh starts for ${label.toLowerCase()}`}
|
|
105
128
|
/>
|
|
106
129
|
</label>
|
|
107
130
|
{field.on && (
|
package/data/chat/useChat.ts
CHANGED
|
@@ -298,10 +298,18 @@ export function useChat(
|
|
|
298
298
|
|
|
299
299
|
const queryClient = useQueryClient();
|
|
300
300
|
const send = useCallback(
|
|
301
|
-
|
|
301
|
+
// `compact`: the reader let a stale chat compact first (lib/chat/stale).
|
|
302
|
+
(text: string, images?: ChatImage[], compact?: boolean) => {
|
|
302
303
|
const ws = wsRef.current;
|
|
303
304
|
if (!ws) return;
|
|
304
|
-
ws.send(
|
|
305
|
+
ws.send(
|
|
306
|
+
JSON.stringify({
|
|
307
|
+
type: "send",
|
|
308
|
+
text,
|
|
309
|
+
images,
|
|
310
|
+
...(compact && { compact: true }),
|
|
311
|
+
})
|
|
312
|
+
);
|
|
305
313
|
if (ws.readyState === WebSocket.OPEN) markRunning(queryClient, sessionId);
|
|
306
314
|
},
|
|
307
315
|
[queryClient, sessionId]
|
package/lib/bus/index.ts
CHANGED
|
@@ -163,6 +163,9 @@ async function deliver(
|
|
|
163
163
|
from: from.name,
|
|
164
164
|
peer: from.peer,
|
|
165
165
|
origin: from.origin,
|
|
166
|
+
// Nobody is at the composer to choose: a stale chat is compacted
|
|
167
|
+
// first (lib/chat/stale).
|
|
168
|
+
compact: "auto",
|
|
166
169
|
});
|
|
167
170
|
return { state };
|
|
168
171
|
} catch (error) {
|
package/lib/chat/client-send.ts
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
import type { ChatImage } from "./events";
|
|
2
|
+
import type { CompactFirst } from "./stale";
|
|
2
3
|
|
|
3
|
-
// What a browser's send may carry: its text and images
|
|
4
|
-
//
|
|
4
|
+
// What a browser's send may carry: its text and images, and whether the
|
|
5
|
+
// reader chose to compact a stale chat first. Who a message is from (`from`,
|
|
6
|
+
// `peer`, `origin`) is set by the server alone, never by a client.
|
|
5
7
|
export function clientSend(msg: unknown): {
|
|
6
8
|
text: string;
|
|
7
9
|
images?: ChatImage[];
|
|
10
|
+
compact?: CompactFirst;
|
|
8
11
|
} {
|
|
9
|
-
const m = (msg ?? {}) as {
|
|
12
|
+
const m = (msg ?? {}) as {
|
|
13
|
+
text?: unknown;
|
|
14
|
+
images?: unknown;
|
|
15
|
+
compact?: unknown;
|
|
16
|
+
};
|
|
10
17
|
return {
|
|
11
18
|
text: typeof m.text === "string" ? m.text : "",
|
|
12
19
|
images: Array.isArray(m.images) ? (m.images as ChatImage[]) : undefined,
|
|
20
|
+
...(m.compact === true && { compact: "yes" as const }),
|
|
13
21
|
};
|
|
14
22
|
}
|
|
@@ -31,6 +31,10 @@ export type ClaudeMessage = {
|
|
|
31
31
|
input_tokens?: number;
|
|
32
32
|
output_tokens?: number;
|
|
33
33
|
cache_creation_input_tokens?: number;
|
|
34
|
+
cache_creation?: {
|
|
35
|
+
ephemeral_5m_input_tokens?: number;
|
|
36
|
+
ephemeral_1h_input_tokens?: number;
|
|
37
|
+
} | null;
|
|
34
38
|
};
|
|
35
39
|
};
|
|
36
40
|
event?: {
|
|
@@ -49,7 +53,11 @@ export type ClaudeMessage = {
|
|
|
49
53
|
content?: string;
|
|
50
54
|
terminal_slash_commands?: string[];
|
|
51
55
|
commands?: SdkCommand[];
|
|
52
|
-
compact_metadata?: {
|
|
56
|
+
compact_metadata?: {
|
|
57
|
+
trigger?: "manual" | "auto";
|
|
58
|
+
pre_tokens?: number;
|
|
59
|
+
post_tokens?: number;
|
|
60
|
+
};
|
|
53
61
|
// Background tasks (task_started, task_progress, task_notification).
|
|
54
62
|
task_id?: string;
|
|
55
63
|
tool_use_id?: string;
|
|
@@ -223,6 +231,8 @@ export class ClaudeMapper {
|
|
|
223
231
|
id: randomUUID(),
|
|
224
232
|
kind: "compacted",
|
|
225
233
|
trigger: m.compact_metadata?.trigger,
|
|
234
|
+
before: m.compact_metadata?.pre_tokens,
|
|
235
|
+
after: m.compact_metadata?.post_tokens,
|
|
226
236
|
createdAt: now(),
|
|
227
237
|
},
|
|
228
238
|
},
|
|
@@ -10,6 +10,7 @@ import type { DriverEvent, FileSuggestion, McpServerView } from "../events";
|
|
|
10
10
|
import { InputQueue } from "../queue";
|
|
11
11
|
import { Approvals, isPlanFile, sdkMode } from "./claude-approvals";
|
|
12
12
|
import { toChatContext, usageTotals } from "../context";
|
|
13
|
+
import { cacheTtlMs } from "../stale";
|
|
13
14
|
import { ClaudeMapper, toCommand, type ClaudeMessage } from "./claude-mapper";
|
|
14
15
|
import { SuggestionTrace } from "./suggestion-trace";
|
|
15
16
|
import { redact } from "../../orchestrator/untrusted";
|
|
@@ -224,6 +225,8 @@ export const claudeDriver: ChatDriver = {
|
|
|
224
225
|
() => plan
|
|
225
226
|
);
|
|
226
227
|
let sessionId = options.resumeId ?? null;
|
|
228
|
+
// The prompt-cache lifetime its turns write with (lib/chat/stale).
|
|
229
|
+
let cacheTtl: number | undefined;
|
|
227
230
|
|
|
228
231
|
// What the agent restored on starting: a resumed conversation's totals
|
|
229
232
|
// when its last process saved them, nothing otherwise. Each turn's cost
|
|
@@ -248,13 +251,18 @@ export const claudeDriver: ChatDriver = {
|
|
|
248
251
|
planFile = writtenPlanFile(m) ?? planFile;
|
|
249
252
|
trace.message(m);
|
|
250
253
|
for (const e of mapper.map(m)) out.push(e);
|
|
254
|
+
if (m.type === "assistant" && !m.parent_tool_use_id)
|
|
255
|
+
cacheTtl = cacheTtlMs(m.message?.usage?.cache_creation) ?? cacheTtl;
|
|
251
256
|
// How full the window is now, for the meter, without holding up
|
|
252
257
|
// the conversation.
|
|
253
258
|
if (m.type === "result")
|
|
254
259
|
void q
|
|
255
260
|
.getContextUsage({ detail: "summary" })
|
|
256
261
|
.then((u) =>
|
|
257
|
-
out.push({
|
|
262
|
+
out.push({
|
|
263
|
+
type: "context",
|
|
264
|
+
context: { ...toChatContext(u), cacheTtlMs: cacheTtl },
|
|
265
|
+
})
|
|
258
266
|
)
|
|
259
267
|
.catch(() => {});
|
|
260
268
|
}
|
package/lib/chat/events.ts
CHANGED
|
@@ -106,7 +106,11 @@ export interface ChatContext {
|
|
|
106
106
|
percentage: number;
|
|
107
107
|
categories: { name: string; tokens: number }[];
|
|
108
108
|
model?: string;
|
|
109
|
+
// When it was measured: the end of the last turn.
|
|
109
110
|
at: number;
|
|
111
|
+
// The prompt-cache lifetime the conversation writes with (lib/chat/stale),
|
|
112
|
+
// once a turn has said.
|
|
113
|
+
cacheTtlMs?: number;
|
|
110
114
|
}
|
|
111
115
|
|
|
112
116
|
// Running totals the agent reports at the end of each turn.
|
|
@@ -174,7 +178,16 @@ export type ChatItem =
|
|
|
174
178
|
| (Base & { kind: "command_output"; text: string })
|
|
175
179
|
// The conversation's MCP servers, as /mcp reports them.
|
|
176
180
|
| (Base & { kind: "mcp"; servers: McpServerView[] })
|
|
177
|
-
| (Base & {
|
|
181
|
+
| (Base & {
|
|
182
|
+
kind: "compacted";
|
|
183
|
+
trigger?: "manual" | "auto";
|
|
184
|
+
// Context tokens before and after, when the agent reports them.
|
|
185
|
+
before?: number;
|
|
186
|
+
after?: number;
|
|
187
|
+
// Compacted ahead of a message to a stale chat (lib/chat/stale): how
|
|
188
|
+
// long it had been idle.
|
|
189
|
+
idleMs?: number;
|
|
190
|
+
})
|
|
178
191
|
// A plan the agent proposed from plan mode, and whether it was carried out.
|
|
179
192
|
| (Base & { kind: "plan"; plan: string; carried?: boolean })
|
|
180
193
|
| (Base & { kind: "error"; message: string })
|
package/lib/chat/refresh.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
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
|
|
9
|
+
* App-wide, in Settings → Workspaces, with the size past which an idle
|
|
10
|
+
* chat is compacted before a message goes in (lib/chat/stale).
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
import { db } from "../db";
|
|
@@ -14,13 +15,18 @@ import { MAX_REFRESH_TOKENS, MIN_REFRESH_TOKENS } from "./refresh-limits";
|
|
|
14
15
|
|
|
15
16
|
export const ORCHESTRATOR_KEY = "orchestrator.refresh_tokens";
|
|
16
17
|
export const TASK_KEY = "chat.task_refresh_tokens";
|
|
18
|
+
export const STALE_COMPACT_KEY = "chat.stale_compact_tokens";
|
|
17
19
|
export const DEFAULT_ORCHESTRATOR_TOKENS = 200_000;
|
|
20
|
+
export const DEFAULT_STALE_COMPACT_TOKENS = 150_000;
|
|
18
21
|
export { MIN_REFRESH_TOKENS, MAX_REFRESH_TOKENS } from "./refresh-limits";
|
|
19
22
|
|
|
20
23
|
export interface RefreshSettings {
|
|
21
24
|
// Context tokens at which a conversation starts over; 0 is off.
|
|
22
25
|
orchestratorTokens: number;
|
|
23
26
|
taskTokens: number;
|
|
27
|
+
// Context tokens above which a chat whose prompt cache has expired is
|
|
28
|
+
// compacted before the next message; 0 is off.
|
|
29
|
+
staleCompactTokens: number;
|
|
24
30
|
}
|
|
25
31
|
|
|
26
32
|
function read(key: string, fallback: number): number {
|
|
@@ -35,6 +41,7 @@ export function refreshSettings(): RefreshSettings {
|
|
|
35
41
|
return {
|
|
36
42
|
orchestratorTokens: read(ORCHESTRATOR_KEY, DEFAULT_ORCHESTRATOR_TOKENS),
|
|
37
43
|
taskTokens: read(TASK_KEY, 0),
|
|
44
|
+
staleCompactTokens: read(STALE_COMPACT_KEY, DEFAULT_STALE_COMPACT_TOKENS),
|
|
38
45
|
};
|
|
39
46
|
}
|
|
40
47
|
|
|
@@ -49,7 +56,7 @@ export function checkRefreshTokens(value: unknown): number {
|
|
|
49
56
|
value > MAX_REFRESH_TOKENS
|
|
50
57
|
)
|
|
51
58
|
throw new Error(
|
|
52
|
-
`A
|
|
59
|
+
`A context threshold is off or between ${MIN_REFRESH_TOKENS / 1000}k and ${MAX_REFRESH_TOKENS / 1000}k tokens`
|
|
53
60
|
);
|
|
54
61
|
return value;
|
|
55
62
|
}
|
|
@@ -67,6 +74,11 @@ export function setRefreshSettings(input: Partial<RefreshSettings>): void {
|
|
|
67
74
|
]);
|
|
68
75
|
if (input.taskTokens !== undefined)
|
|
69
76
|
checked.push([TASK_KEY, checkRefreshTokens(input.taskTokens)]);
|
|
77
|
+
if (input.staleCompactTokens !== undefined)
|
|
78
|
+
checked.push([
|
|
79
|
+
STALE_COMPACT_KEY,
|
|
80
|
+
checkRefreshTokens(input.staleCompactTokens),
|
|
81
|
+
]);
|
|
70
82
|
db.transaction(() => {
|
|
71
83
|
for (const [key, n] of checked) put.run(key, String(n));
|
|
72
84
|
})();
|
package/lib/chat/runner.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { randomUUID } from "crypto";
|
|
9
9
|
import os from "os";
|
|
10
10
|
import { db, type Session } from "../db";
|
|
11
|
+
import type { CompactFirst } from "./stale";
|
|
11
12
|
import type {
|
|
12
13
|
ApprovalDecision,
|
|
13
14
|
ChatContext,
|
|
@@ -329,6 +330,8 @@ export async function sendChat(
|
|
|
329
330
|
images?: ChatImage[];
|
|
330
331
|
// Typed in the composer: waits in the queue while a turn runs.
|
|
331
332
|
queue?: boolean;
|
|
333
|
+
// Compact a stale chat first (lib/chat/stale).
|
|
334
|
+
compact?: CompactFirst;
|
|
332
335
|
} & SentBy
|
|
333
336
|
): Promise<void> {
|
|
334
337
|
const text = input.text.trim();
|
|
@@ -356,6 +359,7 @@ export async function sendChat(
|
|
|
356
359
|
images: input.images,
|
|
357
360
|
...sentBy(input),
|
|
358
361
|
queue: input.queue,
|
|
362
|
+
compact: input.compact,
|
|
359
363
|
});
|
|
360
364
|
}
|
|
361
365
|
|
|
@@ -486,6 +490,8 @@ async function resumeCutOff(sessionId: string): Promise<void> {
|
|
|
486
490
|
text: RESUME_NOTE,
|
|
487
491
|
from: "agentos",
|
|
488
492
|
origin: { kind: "event", label: "AgentOS" },
|
|
493
|
+
// Down past its cache, a long chat is compacted before it carries on.
|
|
494
|
+
compact: "auto",
|
|
489
495
|
});
|
|
490
496
|
if (task)
|
|
491
497
|
queueEvent(
|
|
@@ -552,7 +558,7 @@ export async function chatFileSuggestions(
|
|
|
552
558
|
// only then has it accepted it. "queued" when a turn was already running.
|
|
553
559
|
export async function sendChatConfirmed(
|
|
554
560
|
sessionId: string,
|
|
555
|
-
input: { text: string; id?: string } & SentBy,
|
|
561
|
+
input: { text: string; id?: string; compact?: CompactFirst } & SentBy,
|
|
556
562
|
timeoutMs = 10_000
|
|
557
563
|
): Promise<"delivered" | "queued"> {
|
|
558
564
|
const text = input.text.trim();
|
|
@@ -607,6 +613,7 @@ export async function sendChatConfirmed(
|
|
|
607
613
|
id,
|
|
608
614
|
text,
|
|
609
615
|
...sentBy(input),
|
|
616
|
+
compact: input.compact,
|
|
610
617
|
});
|
|
611
618
|
await accepted;
|
|
612
619
|
} finally {
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stale chats: a message sent to a conversation whose prompt cache has
|
|
3
|
+
* expired re-reads the whole history at the cache-write price. When it is
|
|
4
|
+
* also long, compacting first is far cheaper than carrying it (one summary
|
|
5
|
+
* turn, then a small cache write instead of a huge one). The one check every
|
|
6
|
+
* send path uses: the composer to offer it (components/Chat/StaleChip), the
|
|
7
|
+
* chat worker to do it for automated sends (lib/chat/worker/host).
|
|
8
|
+
*
|
|
9
|
+
* Pure, so the browser and the worker read the same rule.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ChatContext, ChatItem, ChatState } from "./events";
|
|
13
|
+
import { formatTokens } from "./context";
|
|
14
|
+
|
|
15
|
+
// How a send treats a stale chat: "auto" compacts first only when it is
|
|
16
|
+
// stale (automated sends); "yes" compacts first because the reader chose
|
|
17
|
+
// to (the composer's chip). Absent, it never does.
|
|
18
|
+
export type CompactFirst = "auto" | "yes";
|
|
19
|
+
|
|
20
|
+
// The prompt-cache lifetime a turn wrote with, read from its usage: the
|
|
21
|
+
// session runs with one or the other, and only a write says which. A turn
|
|
22
|
+
// that only read the cache says nothing.
|
|
23
|
+
export function cacheTtlMs(
|
|
24
|
+
cacheCreation:
|
|
25
|
+
| {
|
|
26
|
+
ephemeral_5m_input_tokens?: number;
|
|
27
|
+
ephemeral_1h_input_tokens?: number;
|
|
28
|
+
}
|
|
29
|
+
| null
|
|
30
|
+
| undefined
|
|
31
|
+
): number | undefined {
|
|
32
|
+
if (!cacheCreation) return undefined;
|
|
33
|
+
if ((cacheCreation.ephemeral_1h_input_tokens ?? 0) > 0) return 60 * 60_000;
|
|
34
|
+
if ((cacheCreation.ephemeral_5m_input_tokens ?? 0) > 0) return 5 * 60_000;
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface Staleness {
|
|
39
|
+
idleMs: number;
|
|
40
|
+
tokens: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Stale: no turn running, the last one ended longer ago than the cache it
|
|
44
|
+
// wrote lives, and the context is above the threshold (0 is off). Null when
|
|
45
|
+
// it isn't, or when the chat never said which cache it uses.
|
|
46
|
+
export function staleness(
|
|
47
|
+
context: ChatContext | null | undefined,
|
|
48
|
+
opts: { state: ChatState; thresholdTokens: number; now?: number }
|
|
49
|
+
): Staleness | null {
|
|
50
|
+
if (opts.state === "running" || opts.state === "waiting") return null;
|
|
51
|
+
if (!context?.cacheTtlMs || opts.thresholdTokens <= 0) return null;
|
|
52
|
+
const idleMs = (opts.now ?? Date.now()) - context.at;
|
|
53
|
+
if (idleMs <= context.cacheTtlMs) return null;
|
|
54
|
+
if (context.usedTokens <= opts.thresholdTokens) return null;
|
|
55
|
+
return { idleMs, tokens: context.usedTokens };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function formatIdle(ms: number): string {
|
|
59
|
+
const m = Math.floor(ms / 60_000);
|
|
60
|
+
if (m < 120) return `${m}m`;
|
|
61
|
+
const h = Math.floor(m / 60);
|
|
62
|
+
return h < 48 ? `${h}h` : `${Math.floor(h / 24)}d`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// "Idle 47m · 412k context"
|
|
66
|
+
export function staleLabel(s: Staleness): string {
|
|
67
|
+
return `Idle ${formatIdle(s.idleMs)} · ${formatTokens(s.tokens)} context`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Whether the composer's send compacts first: the chip was showing and the
|
|
71
|
+
// reader didn't pick "Send as-is". A slash command goes as typed.
|
|
72
|
+
export function composerCompacts(
|
|
73
|
+
text: string,
|
|
74
|
+
context: ChatContext | null | undefined,
|
|
75
|
+
opts: { state: ChatState; thresholdTokens: number; asIs: boolean }
|
|
76
|
+
): boolean {
|
|
77
|
+
if (opts.asIs || text.trimStart().startsWith("/")) return false;
|
|
78
|
+
return !!staleness(context, opts);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The chat's line for a compaction: "Compacted before sending · idle 47m ·
|
|
82
|
+
// 412k → 38k tokens" when it ran ahead of a message to a stale chat.
|
|
83
|
+
export function compactedLine(
|
|
84
|
+
item: Extract<ChatItem, { kind: "compacted" }>
|
|
85
|
+
): string {
|
|
86
|
+
const head =
|
|
87
|
+
item.idleMs !== undefined
|
|
88
|
+
? `Compacted before sending · idle ${formatIdle(item.idleMs)}`
|
|
89
|
+
: item.trigger === "auto"
|
|
90
|
+
? "Context compacted automatically"
|
|
91
|
+
: "Context compacted";
|
|
92
|
+
if (item.before === undefined) return head;
|
|
93
|
+
const after = item.after === undefined ? "…" : formatTokens(item.after);
|
|
94
|
+
return `${head} · ${formatTokens(item.before)} → ${after} tokens`;
|
|
95
|
+
}
|
package/lib/chat/worker/host.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { resolveModelForAgent } from "../../model-catalog";
|
|
|
13
13
|
import { chatDriverFor } from "../drivers";
|
|
14
14
|
import type { ChatConversation } from "../driver";
|
|
15
15
|
import type {
|
|
16
|
+
ChatContext,
|
|
16
17
|
ChatImage,
|
|
17
18
|
ChatItem,
|
|
18
19
|
ChatState,
|
|
@@ -35,7 +36,8 @@ import {
|
|
|
35
36
|
visualsTools,
|
|
36
37
|
} from "../../artifacts/tools";
|
|
37
38
|
import type { RoleExtras } from "./extras";
|
|
38
|
-
import { refreshedNote } from "../refresh";
|
|
39
|
+
import { refreshSettings, refreshedNote } from "../refresh";
|
|
40
|
+
import { staleLabel, staleness, type Staleness } from "../stale";
|
|
39
41
|
import type { WorkerCommand, WorkerEvent } from "./protocol";
|
|
40
42
|
|
|
41
43
|
// How soon after a stopped turn ends a turn the agent starts on its own
|
|
@@ -123,6 +125,19 @@ export class ChatHost {
|
|
|
123
125
|
// size can't end every conversation after one turn.
|
|
124
126
|
private freshConversation: boolean;
|
|
125
127
|
private contextBase: number | null = null;
|
|
128
|
+
// How full the conversation was at its last turn's end (lib/chat/stale).
|
|
129
|
+
private context: ChatContext | null = null;
|
|
130
|
+
// A compaction run ahead of a message to a stale chat: the message waits
|
|
131
|
+
// at the front of the queue until it ends (compactFirst).
|
|
132
|
+
private compacting: {
|
|
133
|
+
idleMs: number;
|
|
134
|
+
before: number;
|
|
135
|
+
done?: boolean;
|
|
136
|
+
} | null = null;
|
|
137
|
+
// Its line, until the agent's next measure gives the size after.
|
|
138
|
+
private compactedLine: Extract<ChatItem, { kind: "compacted" }> | null = null;
|
|
139
|
+
// An orchestrator gone stale starts over rather than compacting.
|
|
140
|
+
private staleRefresh: Staleness | null = null;
|
|
126
141
|
readonly done: Promise<void>;
|
|
127
142
|
|
|
128
143
|
constructor(
|
|
@@ -182,11 +197,19 @@ export class ChatHost {
|
|
|
182
197
|
permissionMode: extras.permissionMode,
|
|
183
198
|
});
|
|
184
199
|
this.suggestion = session.chat_suggestion ?? null;
|
|
200
|
+
try {
|
|
201
|
+
this.context = session.chat_context
|
|
202
|
+
? (JSON.parse(session.chat_context) as ChatContext)
|
|
203
|
+
: null;
|
|
204
|
+
} catch {
|
|
205
|
+
this.context = null;
|
|
206
|
+
}
|
|
185
207
|
this.refresh = extras.refresh;
|
|
186
208
|
this.freshConversation = !session.claude_session_id;
|
|
187
209
|
if (session.chat_refreshed)
|
|
188
210
|
this.refreshedNote = refreshedNote(
|
|
189
|
-
extras.refresh?.role ??
|
|
211
|
+
extras.refresh?.role ??
|
|
212
|
+
(session.role === "orchestrator" ? "orchestrator" : "task"),
|
|
190
213
|
session.chat_refreshed
|
|
191
214
|
);
|
|
192
215
|
// Told only an orchestrator whose tools changed since its last worker:
|
|
@@ -222,6 +245,12 @@ export class ChatHost {
|
|
|
222
245
|
try {
|
|
223
246
|
for await (const e of this.conversation.events) {
|
|
224
247
|
if (e.type === "item") {
|
|
248
|
+
// A compaction run for a stale chat shows as its one line; what
|
|
249
|
+
// a failed one printed still shows.
|
|
250
|
+
if (this.compacting?.done && e.item.kind === "command_output")
|
|
251
|
+
continue;
|
|
252
|
+
if (this.compacting && e.item.kind === "compacted")
|
|
253
|
+
e.item = this.compactedItem(e.item);
|
|
225
254
|
if ("streaming" in e.item && e.item.streaming)
|
|
226
255
|
this.streaming.set(e.item.id, e.item);
|
|
227
256
|
else this.streaming.delete(e.item.id);
|
|
@@ -259,7 +288,9 @@ export class ChatHost {
|
|
|
259
288
|
JSON.stringify(e.context),
|
|
260
289
|
this.session.id
|
|
261
290
|
);
|
|
291
|
+
this.context = e.context;
|
|
262
292
|
this.emit(e);
|
|
293
|
+
this.settleCompactedLine(e.context.usedTokens);
|
|
263
294
|
this.refreshIfFull(e.context.usedTokens);
|
|
264
295
|
} else if (e.type === "turn_start") {
|
|
265
296
|
this.turnsStarted++;
|
|
@@ -303,6 +334,8 @@ export class ChatHost {
|
|
|
303
334
|
// A send waiting on the same change may have started a turn
|
|
304
335
|
// meanwhile: that turn is running, not idle.
|
|
305
336
|
if (this.busy()) continue;
|
|
337
|
+
// The compaction ran (or failed): its message is first in line.
|
|
338
|
+
this.compacting = null;
|
|
306
339
|
if (this.sendQueued()) continue;
|
|
307
340
|
}
|
|
308
341
|
// A stopped turn can still settle an approval after it ended.
|
|
@@ -503,7 +536,13 @@ export class ChatHost {
|
|
|
503
536
|
this.sendUser(cmd, true);
|
|
504
537
|
return;
|
|
505
538
|
}
|
|
506
|
-
if (
|
|
539
|
+
if (this.compactFirst(cmd)) return;
|
|
540
|
+
// A compaction run for an earlier message takes nothing in: what's
|
|
541
|
+
// sent meanwhile goes behind that message.
|
|
542
|
+
if (
|
|
543
|
+
(cmd.queue || this.compacting) &&
|
|
544
|
+
(this.busy() || listQueue(this.session.id).length)
|
|
545
|
+
) {
|
|
507
546
|
// Behind the running turn, and behind anything queued before it.
|
|
508
547
|
enqueue(this.session.id, cmd);
|
|
509
548
|
this.emit({ type: "queue" });
|
|
@@ -620,6 +659,88 @@ export class ChatHost {
|
|
|
620
659
|
this.close();
|
|
621
660
|
}
|
|
622
661
|
|
|
662
|
+
// A stale chat (lib/chat/stale) is compacted before the message goes in,
|
|
663
|
+
// only between turns: the message waits at the front of the queue, and
|
|
664
|
+
// the compaction's end sends it. "yes" is the reader's choice, made on
|
|
665
|
+
// what the composer showed; "auto" checks for itself.
|
|
666
|
+
private compactFirst(cmd: Extract<WorkerCommand, { type: "send" }>): boolean {
|
|
667
|
+
if (!cmd.compact || this.busy() || this.closed || this.handingOver())
|
|
668
|
+
return false;
|
|
669
|
+
const stale = this.staleNow(cmd.compact);
|
|
670
|
+
if (!stale) return false;
|
|
671
|
+
try {
|
|
672
|
+
enqueue(this.session.id, cmd);
|
|
673
|
+
} catch {
|
|
674
|
+
// A full queue: it goes in as it is.
|
|
675
|
+
return false;
|
|
676
|
+
}
|
|
677
|
+
moveToFront(this.session.id, cmd.id);
|
|
678
|
+
this.emit({ type: "queue" });
|
|
679
|
+
// An orchestrator isn't compacted: a summary would carry what it read
|
|
680
|
+
// from tasks and CI without its untrusted fence. It starts a fresh
|
|
681
|
+
// conversation from its brief and the database instead, as at its
|
|
682
|
+
// threshold, and the next worker sends the message.
|
|
683
|
+
if (this.session.role === "orchestrator") {
|
|
684
|
+
this.staleRefresh = stale;
|
|
685
|
+
this.refreshAt = stale.tokens;
|
|
686
|
+
this.retiring = true;
|
|
687
|
+
this.retireWaited = true;
|
|
688
|
+
this.retireIfDone();
|
|
689
|
+
return true;
|
|
690
|
+
}
|
|
691
|
+
this.compacting = { idleMs: stale.idleMs, before: stale.tokens };
|
|
692
|
+
this.currentTurn = null;
|
|
693
|
+
this.stopping = null;
|
|
694
|
+
this.conversation.send("/compact");
|
|
695
|
+
this.setState("running");
|
|
696
|
+
console.error(
|
|
697
|
+
`${new Date().toISOString()} compacting ${this.session.id} before a message: idle ${Math.round(stale.idleMs / 60_000)}m, ${stale.tokens} tokens`
|
|
698
|
+
);
|
|
699
|
+
return true;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
private staleNow(compact: "auto" | "yes"): Staleness | null {
|
|
703
|
+
const context = this.context;
|
|
704
|
+
// Only an agent that says which cache it uses has one to lose.
|
|
705
|
+
if (!context?.cacheTtlMs) return null;
|
|
706
|
+
if (compact === "yes")
|
|
707
|
+
return { idleMs: Date.now() - context.at, tokens: context.usedTokens };
|
|
708
|
+
return staleness(context, {
|
|
709
|
+
state: this.state,
|
|
710
|
+
thresholdTokens: refreshSettings().staleCompactTokens,
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
private compactedItem(
|
|
715
|
+
item: Extract<ChatItem, { kind: "compacted" }>
|
|
716
|
+
): ChatItem {
|
|
717
|
+
if (this.compacting) this.compacting.done = true;
|
|
718
|
+
const line = {
|
|
719
|
+
...item,
|
|
720
|
+
idleMs: this.compacting?.idleMs,
|
|
721
|
+
before: item.before ?? this.compacting?.before,
|
|
722
|
+
};
|
|
723
|
+
this.compactedLine = line.after === undefined ? line : null;
|
|
724
|
+
if (line.after !== undefined) this.logCompacted(line);
|
|
725
|
+
return line;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// The size after, from the measure that follows the compaction's result.
|
|
729
|
+
private settleCompactedLine(usedTokens: number): void {
|
|
730
|
+
const line = this.compactedLine;
|
|
731
|
+
if (!line) return;
|
|
732
|
+
this.compactedLine = null;
|
|
733
|
+
const settled = { ...line, after: usedTokens };
|
|
734
|
+
this.record(settled);
|
|
735
|
+
this.logCompacted(settled);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
private logCompacted(line: { before?: number; after?: number }): void {
|
|
739
|
+
console.error(
|
|
740
|
+
`${new Date().toISOString()} compacted ${this.session.id}: ${line.before ?? "?"} -> ${line.after ?? "?"} tokens`
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
|
|
623
744
|
// Past its threshold, once: it retires at the next turn boundary, as for
|
|
624
745
|
// a deploy, without waiting for the agent's guess at the next message
|
|
625
746
|
// (the new conversation never saw what it guessed from).
|
|
@@ -643,13 +764,21 @@ export class ChatHost {
|
|
|
643
764
|
chat_suggestion = NULL, chat_context = NULL, chat_restarted = 0,
|
|
644
765
|
chat_refreshed = ? WHERE id = ?`
|
|
645
766
|
).run(this.refreshAt, this.session.id);
|
|
767
|
+
const brief = `its brief${this.session.role === "orchestrator" ? " and the current state from AgentOS" : ""}`;
|
|
768
|
+
const stale = this.staleRefresh;
|
|
646
769
|
this.record({
|
|
647
770
|
id: `note-refresh-${Date.now()}`,
|
|
648
771
|
kind: "note",
|
|
649
772
|
tone: "note",
|
|
650
|
-
text:
|
|
773
|
+
text: stale
|
|
774
|
+
? `${staleLabel(stale)}, past its prompt cache: the agent starts a fresh conversation with ${brief} before the next message.`
|
|
775
|
+
: `Context reached ${Math.round(this.refreshAt / 1000)}k tokens: the agent starts a fresh conversation with ${brief} at its next message.`,
|
|
651
776
|
createdAt: Date.now(),
|
|
652
777
|
});
|
|
778
|
+
if (stale)
|
|
779
|
+
console.error(
|
|
780
|
+
`${new Date().toISOString()} fresh start for stale ${this.session.id}: ${stale.tokens} tokens, idle ${Math.round(stale.idleMs / 60_000)}m`
|
|
781
|
+
);
|
|
653
782
|
}
|
|
654
783
|
|
|
655
784
|
// Retiring, and nothing in the background keeps it: messages wait in the
|
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
PeerMessage,
|
|
19
19
|
} from "../events";
|
|
20
20
|
import type { UndoResult } from "../driver";
|
|
21
|
+
import type { CompactFirst } from "../stale";
|
|
21
22
|
|
|
22
23
|
export const PROTOCOL_VERSION = 1;
|
|
23
24
|
|
|
@@ -35,6 +36,8 @@ export type WorkerCommand =
|
|
|
35
36
|
// From the composer: waits its turn behind a running one, in the
|
|
36
37
|
// queue, rather than joining it.
|
|
37
38
|
queue?: boolean;
|
|
39
|
+
// Compact a stale conversation before this goes in (lib/chat/stale).
|
|
40
|
+
compact?: CompactFirst;
|
|
38
41
|
}
|
|
39
42
|
// A queued message goes next: the running turn stops for it.
|
|
40
43
|
// `during`: the user message whose turn the reader saw running; only that
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ChatOrigin, ChatState } from "../chat/events";
|
|
2
|
+
import type { CompactFirst } from "../chat/stale";
|
|
2
3
|
import {
|
|
3
4
|
isAnswer,
|
|
4
5
|
deliveriesSince,
|
|
@@ -22,7 +23,12 @@ export const AGENTOS_SENDER = "agentos";
|
|
|
22
23
|
|
|
23
24
|
export type ChatSender = (
|
|
24
25
|
sessionId: string,
|
|
25
|
-
input: {
|
|
26
|
+
input: {
|
|
27
|
+
text: string;
|
|
28
|
+
from: string;
|
|
29
|
+
origin: ChatOrigin;
|
|
30
|
+
compact: CompactFirst;
|
|
31
|
+
}
|
|
26
32
|
) => Promise<void>;
|
|
27
33
|
|
|
28
34
|
// After a failed send, wait 5s, then 10s, 20s ... up to 5 minutes.
|
|
@@ -97,6 +103,8 @@ export async function deliverEvents(opts: {
|
|
|
97
103
|
text,
|
|
98
104
|
from: AGENTOS_SENDER,
|
|
99
105
|
origin,
|
|
106
|
+
// An orchestrator idle past its cache is compacted first.
|
|
107
|
+
compact: "auto",
|
|
100
108
|
});
|
|
101
109
|
failing.delete(workspaceId);
|
|
102
110
|
return text;
|
package/lib/schedules/start.ts
CHANGED
|
@@ -139,6 +139,7 @@ async function postToOrchestrator(
|
|
|
139
139
|
? `${fromLabel(schedule)}, set up by "${creator.name}"`
|
|
140
140
|
: fromLabel(schedule),
|
|
141
141
|
origin: scheduleOrigin(schedule, creator),
|
|
142
|
+
compact: "auto",
|
|
142
143
|
});
|
|
143
144
|
onSession(orchestrator.id);
|
|
144
145
|
return orchestrator.id;
|