@saadnvd1/agent-os 0.3.470 → 0.3.472
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 +139 -36
- package/app/api/fresh-start/route.ts +9 -5
- package/app/api/models/route.ts +34 -0
- package/app/api/models/test/route.ts +17 -0
- package/app/api/sessions/[id]/summarize/route.ts +4 -1
- package/app/api/tasks/[id]/model/route.ts +23 -0
- package/app/api/tasks/route.ts +5 -1
- package/app/api/usage/compare/route.ts +21 -0
- package/app/api/usage/route.ts +8 -3
- package/app/api/workspaces/[id]/models/route.ts +27 -0
- package/components/Chat/ChatPanel.tsx +5 -5
- package/components/Chat/StaleChip.tsx +7 -6
- package/components/CommandPalette/useAppCommands.ts +19 -1
- package/components/Draft/DraftChips.tsx +28 -0
- package/components/Draft/DraftPanel.tsx +1 -1
- package/components/Models/ModelSelect.tsx +67 -0
- package/components/Models/ModelsSection.tsx +199 -0
- package/components/Models/ProviderCard.tsx +271 -0
- package/components/SessionList/SidebarHeader.tsx +5 -1
- package/components/Settings/SettingsDialog.test.ts +3 -0
- package/components/Settings/SettingsDialog.tsx +9 -0
- package/components/Settings/commands.ts +19 -0
- package/components/Settings/entry-points.test.ts +1 -0
- package/components/Tasks/ResumeOnModel.tsx +72 -0
- package/components/Tasks/TaskRow.tsx +4 -0
- package/components/Usage/CompareView.tsx +211 -0
- package/components/Usage/ModelSpend.tsx +133 -0
- package/components/Usage/UsageDialog.tsx +69 -5
- package/components/Usage/format.ts +9 -0
- package/components/Workspaces/FreshStartForm.tsx +13 -13
- package/components/Workspaces/WorkspaceModelsForm.tsx +71 -0
- package/components/Workspaces/WorkspaceSettings.tsx +2 -0
- package/data/models/index.ts +125 -0
- package/data/usage/index.ts +5 -0
- package/lib/agents/launch.ts +13 -2
- package/lib/chat/context.ts +1 -1
- package/lib/chat/refresh.ts +31 -13
- package/lib/chat/runner.ts +5 -0
- package/lib/chat/stale.ts +25 -16
- package/lib/chat/worker/extras.ts +12 -0
- package/lib/chat/worker/host.ts +70 -24
- package/lib/chat/worker/main.ts +5 -2
- package/lib/db/migrations.ts +70 -0
- package/lib/db/types.ts +6 -0
- package/lib/drafts.ts +3 -0
- package/lib/models/config.ts +218 -0
- package/lib/models/launch.ts +93 -0
- package/lib/models/plan-step.ts +94 -0
- package/lib/models/providers.ts +163 -0
- package/lib/models/resolve.ts +246 -0
- package/lib/models/resume.ts +99 -0
- package/lib/models/roles.ts +98 -0
- package/lib/models/secrets.ts +86 -0
- package/lib/models/session-model.ts +40 -0
- package/lib/models/test-provider.ts +116 -0
- package/lib/orchestrator/act.ts +13 -1
- package/lib/orchestrator/armed.ts +1 -2
- package/lib/orchestrator/brief.ts +5 -4
- package/lib/orchestrator/claude-cli.ts +48 -14
- package/lib/orchestrator/diff.ts +1 -1
- package/lib/orchestrator/external-gates.ts +8 -43
- package/lib/orchestrator/gates.ts +10 -10
- package/lib/orchestrator/home.ts +2 -2
- package/lib/orchestrator/pr-wake.ts +2 -2
- package/lib/orchestrator/rebase.ts +6 -45
- package/lib/orchestrator/review.ts +20 -5
- package/lib/orchestrator/scope.ts +11 -0
- package/lib/orchestrator/serve.ts +13 -0
- package/lib/orchestrator/signoff.ts +13 -8
- package/lib/orchestrator/tool-names.ts +1 -0
- package/lib/orchestrator/tool-schemas.ts +38 -0
- package/lib/orchestrator/tools.ts +3 -1
- package/lib/pr-generation.ts +5 -2
- package/lib/providers/registry.ts +1 -1
- package/lib/session-audit/render.ts +1 -1
- package/lib/session-titles.ts +1 -1
- package/lib/sessions/launch.ts +19 -4
- package/lib/settings-url.ts +1 -0
- package/lib/stacks/land.ts +23 -16
- package/lib/tasks/brief.ts +3 -14
- package/lib/tasks/code-review.ts +69 -107
- package/lib/tasks/gh.ts +3 -2
- package/lib/tasks/index.ts +60 -9
- package/lib/tasks/queue.ts +14 -5
- package/lib/tasks/start.ts +7 -2
- package/lib/tasks/state.ts +4 -4
- package/lib/usage/by-model.ts +105 -0
- package/lib/usage/check-runs.ts +170 -0
- package/lib/usage/compare.ts +280 -0
- package/lib/usage/cost.ts +71 -0
- package/lib/usage/report.ts +51 -0
- package/lib/usage/turns.ts +107 -18
- package/package.json +1 -1
- package/stores/usageUi.ts +11 -2
- package/scripts/check-code-review.ts +0 -21
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { ModelSelect } from "@/components/Models/ModelSelect";
|
|
5
|
+
import {
|
|
6
|
+
useModelsQuery,
|
|
7
|
+
useUpdateWorkspaceModels,
|
|
8
|
+
useWorkspaceModelsQuery,
|
|
9
|
+
} from "@/data/models";
|
|
10
|
+
import { MODEL_ROLES, ROLE_LABELS, type ModelRole } from "@/lib/models/roles";
|
|
11
|
+
|
|
12
|
+
// The roles that run inside a workspace; interactive sessions and helpers
|
|
13
|
+
// follow Settings.
|
|
14
|
+
const WORKSPACE_ROLES = MODEL_ROLES.filter(
|
|
15
|
+
(r): r is Exclude<ModelRole, "interactive" | "helper"> =>
|
|
16
|
+
r !== "interactive" && r !== "helper"
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
// A workspace's own role models, over Settings > Models.
|
|
20
|
+
export function WorkspaceModelsForm({ workspaceId }: { workspaceId: string }) {
|
|
21
|
+
const [open, setOpen] = useState(false);
|
|
22
|
+
const models = useModelsQuery(open);
|
|
23
|
+
const own = useWorkspaceModelsQuery(workspaceId);
|
|
24
|
+
const update = useUpdateWorkspaceModels(workspaceId);
|
|
25
|
+
const roles = own.data?.roles ?? {};
|
|
26
|
+
const set = Object.keys(roles).length;
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className="space-y-2">
|
|
30
|
+
<button
|
|
31
|
+
type="button"
|
|
32
|
+
className="text-muted-foreground hover:text-foreground h-11 text-left text-xs md:h-auto"
|
|
33
|
+
onClick={() => setOpen((o) => !o)}
|
|
34
|
+
aria-expanded={open}
|
|
35
|
+
>
|
|
36
|
+
Models: {set ? `${set} set for this workspace` : "as Settings › Models"}{" "}
|
|
37
|
+
{open ? "▴" : "▾"}
|
|
38
|
+
</button>
|
|
39
|
+
{open && (
|
|
40
|
+
<ul className="space-y-2">
|
|
41
|
+
{WORKSPACE_ROLES.map((role) => (
|
|
42
|
+
<li key={role} className="space-y-1">
|
|
43
|
+
<label
|
|
44
|
+
htmlFor={`ws-${workspaceId}-${role}`}
|
|
45
|
+
className="text-muted-foreground block text-xs"
|
|
46
|
+
>
|
|
47
|
+
{ROLE_LABELS[role].label}
|
|
48
|
+
</label>
|
|
49
|
+
<ModelSelect
|
|
50
|
+
id={`ws-${workspaceId}-${role}`}
|
|
51
|
+
value={roles[role] ?? null}
|
|
52
|
+
effective={models.data?.effective}
|
|
53
|
+
inheritLabel={`As Settings (${models.data?.effective.roles[role] ?? "…"})`}
|
|
54
|
+
disabled={update.isPending || !models.data}
|
|
55
|
+
onChange={(ref) => {
|
|
56
|
+
const next = { ...roles };
|
|
57
|
+
if (ref) next[role] = ref;
|
|
58
|
+
else delete next[role];
|
|
59
|
+
update.mutate({ roles: next });
|
|
60
|
+
}}
|
|
61
|
+
/>
|
|
62
|
+
</li>
|
|
63
|
+
))}
|
|
64
|
+
</ul>
|
|
65
|
+
)}
|
|
66
|
+
{update.isError && (
|
|
67
|
+
<p className="text-destructive text-xs">{update.error.message}</p>
|
|
68
|
+
)}
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -5,6 +5,7 @@ import { useUpdateWorkspace, useWorkspacesQuery } from "@/data/workspaces";
|
|
|
5
5
|
import { TaskLimitForm } from "./TaskLimitForm";
|
|
6
6
|
import { RulesForm } from "./RulesForm";
|
|
7
7
|
import { FreshStartForm } from "./FreshStartForm";
|
|
8
|
+
import { WorkspaceModelsForm } from "./WorkspaceModelsForm";
|
|
8
9
|
|
|
9
10
|
// Settings > Workspaces: each workspace's own settings.
|
|
10
11
|
export function WorkspaceSettings() {
|
|
@@ -41,6 +42,7 @@ export function WorkspaceSettings() {
|
|
|
41
42
|
workspace={w}
|
|
42
43
|
/>
|
|
43
44
|
<RulesForm workspaceId={w.id} />
|
|
45
|
+
<WorkspaceModelsForm workspaceId={w.id} />
|
|
44
46
|
<SettingToggle
|
|
45
47
|
title="Wake tasks on CI failures and reviews"
|
|
46
48
|
detail="A task's agent is messaged once when a check fails on its PR's head, with the log's tail, and when someone reviews or comments on it."
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import type {
|
|
3
|
+
EffectiveModels,
|
|
4
|
+
ModelSettings,
|
|
5
|
+
WorkspaceModels,
|
|
6
|
+
} from "@/lib/models/config";
|
|
7
|
+
import type { ModelRole } from "@/lib/models/roles";
|
|
8
|
+
import type { ProviderTest } from "@/lib/models/test-provider";
|
|
9
|
+
import type { ResumeResult } from "@/lib/models/resume";
|
|
10
|
+
import type { ModelOverride } from "@/lib/models/resolve";
|
|
11
|
+
import type { Comparison } from "@/lib/usage/compare";
|
|
12
|
+
|
|
13
|
+
// Model routing (lib/models): role models, providers, prices.
|
|
14
|
+
export const modelKeys = {
|
|
15
|
+
all: ["models"] as const,
|
|
16
|
+
workspace: (id: string) => [...modelKeys.all, "workspace", id] as const,
|
|
17
|
+
compare: (days: number) => [...modelKeys.all, "compare", days] as const,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export interface ModelsResponse {
|
|
21
|
+
saved: ModelSettings;
|
|
22
|
+
effective: EffectiveModels;
|
|
23
|
+
defaults: Record<ModelRole, string>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function json<T>(res: Response): Promise<T> {
|
|
27
|
+
const data = await res.json().catch(() => ({}));
|
|
28
|
+
if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
|
|
29
|
+
return data;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const send = (url: string, method: string, body: unknown) =>
|
|
33
|
+
fetch(url, {
|
|
34
|
+
method,
|
|
35
|
+
headers: { "Content-Type": "application/json" },
|
|
36
|
+
body: JSON.stringify(body),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export function useModelsQuery(enabled = true) {
|
|
40
|
+
return useQuery({
|
|
41
|
+
queryKey: modelKeys.all,
|
|
42
|
+
queryFn: async () => json<ModelsResponse>(await fetch("/api/models")),
|
|
43
|
+
staleTime: 60_000,
|
|
44
|
+
enabled,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function useUpdateModels() {
|
|
49
|
+
const queryClient = useQueryClient();
|
|
50
|
+
return useMutation({
|
|
51
|
+
mutationFn: async (settings: ModelSettings) =>
|
|
52
|
+
json<ModelsResponse>(await send("/api/models", "PUT", settings)),
|
|
53
|
+
onSuccess: (data) => queryClient.setQueryData(modelKeys.all, data),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function useTestProvider() {
|
|
58
|
+
return useMutation({
|
|
59
|
+
mutationFn: async (input: { provider: string; model?: string }) =>
|
|
60
|
+
json<ProviderTest>(await send("/api/models/test", "POST", input)),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function useWorkspaceModelsQuery(workspaceId: string) {
|
|
65
|
+
return useQuery({
|
|
66
|
+
queryKey: modelKeys.workspace(workspaceId),
|
|
67
|
+
queryFn: async () =>
|
|
68
|
+
json<WorkspaceModels>(
|
|
69
|
+
await fetch(`/api/workspaces/${workspaceId}/models`)
|
|
70
|
+
),
|
|
71
|
+
staleTime: 60_000,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function useUpdateWorkspaceModels(workspaceId: string) {
|
|
76
|
+
const queryClient = useQueryClient();
|
|
77
|
+
return useMutation({
|
|
78
|
+
mutationFn: async (models: WorkspaceModels) =>
|
|
79
|
+
json<WorkspaceModels>(
|
|
80
|
+
await send(`/api/workspaces/${workspaceId}/models`, "PUT", models)
|
|
81
|
+
),
|
|
82
|
+
onSuccess: (data) =>
|
|
83
|
+
queryClient.setQueryData(modelKeys.workspace(workspaceId), data),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function useResumeOnModel() {
|
|
88
|
+
return useMutation({
|
|
89
|
+
mutationFn: async (input: {
|
|
90
|
+
taskId: string;
|
|
91
|
+
model: ModelOverride;
|
|
92
|
+
message?: string;
|
|
93
|
+
}) =>
|
|
94
|
+
json<ResumeResult>(
|
|
95
|
+
await send(`/api/tasks/${input.taskId}/model`, "POST", {
|
|
96
|
+
model: input.model,
|
|
97
|
+
message: input.message,
|
|
98
|
+
})
|
|
99
|
+
),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function useCompareQuery(days: number, enabled = true) {
|
|
104
|
+
return useQuery({
|
|
105
|
+
queryKey: modelKeys.compare(days),
|
|
106
|
+
queryFn: async () =>
|
|
107
|
+
json<Comparison>(await fetch(`/api/usage/compare?days=${days}`)),
|
|
108
|
+
enabled,
|
|
109
|
+
staleTime: 60_000,
|
|
110
|
+
placeholderData: (prev) => prev,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Every model a role can be set to: each usable provider's, as references.
|
|
115
|
+
export function modelChoices(effective: EffectiveModels | undefined) {
|
|
116
|
+
return (effective?.providers ?? [])
|
|
117
|
+
.filter((p) => p.enabled)
|
|
118
|
+
.flatMap((p) =>
|
|
119
|
+
p.models.map((m) => ({
|
|
120
|
+
ref: `${p.id}/${m}`,
|
|
121
|
+
label: m,
|
|
122
|
+
provider: p.label,
|
|
123
|
+
}))
|
|
124
|
+
);
|
|
125
|
+
}
|
package/data/usage/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useQuery } from "@tanstack/react-query";
|
|
2
2
|
import type { UsageRange, UsageReport } from "@/lib/usage/aggregate";
|
|
3
3
|
import type { UsageWindows } from "@/lib/usage/windows";
|
|
4
|
+
import type { SpendByModel } from "@/lib/usage/by-model";
|
|
4
5
|
|
|
5
6
|
export const usageKeys = {
|
|
6
7
|
all: ["usage"] as const,
|
|
@@ -9,7 +10,11 @@ export const usageKeys = {
|
|
|
9
10
|
|
|
10
11
|
export interface UsageResponse {
|
|
11
12
|
report: UsageReport;
|
|
13
|
+
// The orchestrator's reviewer and scope check runs, by PR.
|
|
14
|
+
reviews: UsageReport;
|
|
12
15
|
windows: UsageWindows;
|
|
16
|
+
// By model and role, in the plan's 5-hour window and the last 24 hours.
|
|
17
|
+
models: { fiveHour: SpendByModel; day: SpendByModel };
|
|
13
18
|
}
|
|
14
19
|
|
|
15
20
|
export function useUsageQuery(range: UsageRange, enabled = true) {
|
package/lib/agents/launch.ts
CHANGED
|
@@ -4,11 +4,13 @@ import path from "path";
|
|
|
4
4
|
import { execFile } from "child_process";
|
|
5
5
|
import { promisify } from "util";
|
|
6
6
|
import { getProvider } from "../providers";
|
|
7
|
+
import { sessionShellEnv } from "../models/launch";
|
|
7
8
|
import { shellQuote } from "../hosts/ssh";
|
|
8
9
|
import { runInBackground } from "../async-operations";
|
|
9
10
|
import { trustPromptKeys } from "../tasks/state";
|
|
10
11
|
import { BUS_BRIEF } from "./brief";
|
|
11
12
|
import { selfUrl } from "./self-env";
|
|
13
|
+
import { autoCompactEnv } from "../chat/refresh";
|
|
12
14
|
import { CLAUDE_STATUS_SETTINGS_FLAG } from "../program-status/claude-flag";
|
|
13
15
|
import {
|
|
14
16
|
sessionProjectEnv,
|
|
@@ -35,6 +37,7 @@ export function ensureBusBrief(): void {
|
|
|
35
37
|
export function agentEnv(sessionId: string): Record<string, string> {
|
|
36
38
|
return {
|
|
37
39
|
...sessionProjectEnv(sessionId),
|
|
40
|
+
...autoCompactEnv(sessionId),
|
|
38
41
|
AGENTOS_SESSION_ID: sessionId,
|
|
39
42
|
AGENTOS_URL: selfUrl(),
|
|
40
43
|
PATH: `${AOS_BIN_DIR}:${process.env.PATH ?? ""}`,
|
|
@@ -62,6 +65,8 @@ export async function launchClaude(opts: {
|
|
|
62
65
|
tmuxName: string;
|
|
63
66
|
cwd: string;
|
|
64
67
|
model: string;
|
|
68
|
+
// The model's provider (lib/models); Anthropic when absent.
|
|
69
|
+
provider?: string | null;
|
|
65
70
|
prompt: string;
|
|
66
71
|
brief?: string;
|
|
67
72
|
// Continue this Claude conversation; the prompt is its next message.
|
|
@@ -86,7 +91,13 @@ export async function launchClaude(opts: {
|
|
|
86
91
|
sessionId: opts.resume,
|
|
87
92
|
})
|
|
88
93
|
.join(" ");
|
|
89
|
-
|
|
94
|
+
// The provider's key is fetched by the pane's own shell from its
|
|
95
|
+
// reference: only the reference is in this command line.
|
|
96
|
+
const routing = sessionShellEnv({
|
|
97
|
+
model: opts.model,
|
|
98
|
+
model_provider: opts.provider ?? null,
|
|
99
|
+
});
|
|
100
|
+
const agent = `export PATH=${shellQuote(AOS_BIN_DIR)}:"$HOME/.local/bin:$PATH"; ${routing.exportKey} ${provider.command} ${flags} ${CLAUDE_STATUS_SETTINGS_FLAG} --append-system-prompt-file ${shellQuote(briefFile)} "$(cat ${shellQuote(promptFile)})"; exec "\${SHELL:-/bin/sh}" -l`;
|
|
90
101
|
await execFileAsync(
|
|
91
102
|
"tmux",
|
|
92
103
|
[
|
|
@@ -96,7 +107,7 @@ export async function launchClaude(opts: {
|
|
|
96
107
|
opts.tmuxName,
|
|
97
108
|
"-c",
|
|
98
109
|
opts.cwd,
|
|
99
|
-
...envArgs(agentEnv(opts.sessionId)),
|
|
110
|
+
...envArgs({ ...agentEnv(opts.sessionId), ...routing.env }),
|
|
100
111
|
agent,
|
|
101
112
|
],
|
|
102
113
|
{ cwd: opts.cwd }
|
package/lib/chat/context.ts
CHANGED
package/lib/chat/refresh.ts
CHANGED
|
@@ -6,8 +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, with the size
|
|
10
|
-
*
|
|
9
|
+
* App-wide, in Settings → Workspaces, with the size at which chats and
|
|
10
|
+
* tasks compact (lib/chat/stale).
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { db } from "../db";
|
|
@@ -15,18 +15,20 @@ import { MAX_REFRESH_TOKENS, MIN_REFRESH_TOKENS } from "./refresh-limits";
|
|
|
15
15
|
|
|
16
16
|
export const ORCHESTRATOR_KEY = "orchestrator.refresh_tokens";
|
|
17
17
|
export const TASK_KEY = "chat.task_refresh_tokens";
|
|
18
|
-
|
|
18
|
+
// Named for what it first did: compact chats idle past their cache.
|
|
19
|
+
export const COMPACT_KEY = "chat.stale_compact_tokens";
|
|
19
20
|
export const DEFAULT_ORCHESTRATOR_TOKENS = 200_000;
|
|
20
|
-
export const
|
|
21
|
+
export const DEFAULT_COMPACT_TOKENS = 150_000;
|
|
21
22
|
export { MIN_REFRESH_TOKENS, MAX_REFRESH_TOKENS } from "./refresh-limits";
|
|
22
23
|
|
|
23
24
|
export interface RefreshSettings {
|
|
24
25
|
// Context tokens at which a conversation starts over; 0 is off.
|
|
25
26
|
orchestratorTokens: number;
|
|
26
27
|
taskTokens: number;
|
|
27
|
-
// Context tokens
|
|
28
|
-
//
|
|
29
|
-
|
|
28
|
+
// Context tokens at which a chat's or task's agent compacts: Claude
|
|
29
|
+
// Code's own auto-compact window, and between turns a chat over it (or
|
|
30
|
+
// idle past its cache) is compacted before the next message. 0 is off.
|
|
31
|
+
compactTokens: number;
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
function read(key: string, fallback: number): number {
|
|
@@ -41,7 +43,7 @@ export function refreshSettings(): RefreshSettings {
|
|
|
41
43
|
return {
|
|
42
44
|
orchestratorTokens: read(ORCHESTRATOR_KEY, DEFAULT_ORCHESTRATOR_TOKENS),
|
|
43
45
|
taskTokens: read(TASK_KEY, 0),
|
|
44
|
-
|
|
46
|
+
compactTokens: read(COMPACT_KEY, DEFAULT_COMPACT_TOKENS),
|
|
45
47
|
};
|
|
46
48
|
}
|
|
47
49
|
|
|
@@ -74,16 +76,32 @@ export function setRefreshSettings(input: Partial<RefreshSettings>): void {
|
|
|
74
76
|
]);
|
|
75
77
|
if (input.taskTokens !== undefined)
|
|
76
78
|
checked.push([TASK_KEY, checkRefreshTokens(input.taskTokens)]);
|
|
77
|
-
if (input.
|
|
78
|
-
checked.push([
|
|
79
|
-
STALE_COMPACT_KEY,
|
|
80
|
-
checkRefreshTokens(input.staleCompactTokens),
|
|
81
|
-
]);
|
|
79
|
+
if (input.compactTokens !== undefined)
|
|
80
|
+
checked.push([COMPACT_KEY, checkRefreshTokens(input.compactTokens)]);
|
|
82
81
|
db.transaction(() => {
|
|
83
82
|
for (const [key, n] of checked) put.run(key, String(n));
|
|
84
83
|
})();
|
|
85
84
|
}
|
|
86
85
|
|
|
86
|
+
// Claude Code's own auto-compact window, in plain tokens (it reads "150k"
|
|
87
|
+
// as 150 and clamps anything under 100k up to it).
|
|
88
|
+
export const AUTO_COMPACT_ENV = "CLAUDE_CODE_AUTO_COMPACT_WINDOW";
|
|
89
|
+
const AUTO_COMPACT_MIN = 100_000;
|
|
90
|
+
|
|
91
|
+
// What an agent is launched with so it compacts itself at the threshold.
|
|
92
|
+
// Not an orchestrator: it starts over from its brief at its own threshold
|
|
93
|
+
// instead, since a summary would carry what it read from tasks and CI
|
|
94
|
+
// without its untrusted fence. Nothing when it's off, so a window set in
|
|
95
|
+
// the user's own environment still applies.
|
|
96
|
+
export function autoCompactEnv(sessionId: string): Record<string, string> {
|
|
97
|
+
const row = db
|
|
98
|
+
.prepare(`SELECT role FROM sessions WHERE id = ?`)
|
|
99
|
+
.get(sessionId) as { role: string | null } | undefined;
|
|
100
|
+
const tokens = refreshSettings().compactTokens;
|
|
101
|
+
if (row?.role === "orchestrator" || tokens <= 0) return {};
|
|
102
|
+
return { [AUTO_COMPACT_ENV]: String(Math.max(AUTO_COMPACT_MIN, tokens)) };
|
|
103
|
+
}
|
|
104
|
+
|
|
87
105
|
const k = (tokens: number) => `${Math.round(tokens / 1000)}k`;
|
|
88
106
|
|
|
89
107
|
// What the agent reads ahead of its first message in the new conversation.
|
package/lib/chat/runner.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* events to whoever is watching. Restarting the server leaves turns running.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { handoffAfterPlan } from "../models/plan-step";
|
|
8
9
|
import { randomUUID } from "crypto";
|
|
9
10
|
import os from "os";
|
|
10
11
|
import { db, type Session } from "../db";
|
|
@@ -163,6 +164,10 @@ function onWorkerEvent(sessionId: string, live: Live, e: WorkerEvent): void {
|
|
|
163
164
|
clearTimeout(live.retiring);
|
|
164
165
|
live.retiring = undefined;
|
|
165
166
|
retireIfStale(sessionId, live);
|
|
167
|
+
if (e.state === "idle")
|
|
168
|
+
void handoffAfterPlan(sessionId, (id, m) => sendQueuedNow(id, m)).catch(
|
|
169
|
+
(err) => console.error(`Plan handoff for ${sessionId} failed:`, err)
|
|
170
|
+
);
|
|
166
171
|
} else if (e.type === "suggestion") {
|
|
167
172
|
emit(sessionId, e);
|
|
168
173
|
if (live.retiring && e.text) retire(sessionId, live);
|
package/lib/chat/stale.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
2
|
+
* Compacting between turns. A chat's agent compacts itself at the compact
|
|
3
|
+
* threshold (Claude Code's auto-compact window, set at launch by
|
|
4
|
+
* lib/agents/launch). Between turns AgentOS catches what it didn't: a chat
|
|
5
|
+
* over the threshold is compacted before the next message, and so is one
|
|
6
|
+
* whose prompt cache has expired, since that message would re-read the
|
|
7
|
+
* whole history at the cache-write price (one summary turn, then a small
|
|
8
|
+
* cache write instead of a huge one). The one check every send path uses:
|
|
9
|
+
* the composer to offer it (components/Chat/StaleChip), the chat worker to
|
|
10
|
+
* do it for automated sends (lib/chat/worker/host).
|
|
8
11
|
*
|
|
9
12
|
* Pure, so the browser and the worker read the same rule.
|
|
10
13
|
*/
|
|
@@ -38,21 +41,26 @@ export function cacheTtlMs(
|
|
|
38
41
|
export interface Staleness {
|
|
39
42
|
idleMs: number;
|
|
40
43
|
tokens: number;
|
|
44
|
+
// Idle past the cache its last turn wrote, not only over the threshold.
|
|
45
|
+
stale: boolean;
|
|
41
46
|
}
|
|
42
47
|
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
export function
|
|
48
|
+
// Due a compaction before the next message: no turn running and the
|
|
49
|
+
// context above the threshold (0 is off). Null when it isn't, or when the
|
|
50
|
+
// chat never said which cache it uses (only Claude's chats do).
|
|
51
|
+
export function compactionDue(
|
|
47
52
|
context: ChatContext | null | undefined,
|
|
48
53
|
opts: { state: ChatState; thresholdTokens: number; now?: number }
|
|
49
54
|
): Staleness | null {
|
|
50
55
|
if (opts.state === "running" || opts.state === "waiting") return null;
|
|
51
56
|
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
57
|
if (context.usedTokens <= opts.thresholdTokens) return null;
|
|
55
|
-
|
|
58
|
+
const idleMs = (opts.now ?? Date.now()) - context.at;
|
|
59
|
+
return {
|
|
60
|
+
idleMs,
|
|
61
|
+
tokens: context.usedTokens,
|
|
62
|
+
stale: idleMs > context.cacheTtlMs,
|
|
63
|
+
};
|
|
56
64
|
}
|
|
57
65
|
|
|
58
66
|
export function formatIdle(ms: number): string {
|
|
@@ -62,9 +70,10 @@ export function formatIdle(ms: number): string {
|
|
|
62
70
|
return h < 48 ? `${h}h` : `${Math.floor(h / 24)}d`;
|
|
63
71
|
}
|
|
64
72
|
|
|
65
|
-
// "Idle 47m · 412k context"
|
|
73
|
+
// "Idle 47m · 412k context", or "412k context" while its cache is warm.
|
|
66
74
|
export function staleLabel(s: Staleness): string {
|
|
67
|
-
|
|
75
|
+
const size = `${formatTokens(s.tokens)} context`;
|
|
76
|
+
return s.stale ? `Idle ${formatIdle(s.idleMs)} · ${size}` : size;
|
|
68
77
|
}
|
|
69
78
|
|
|
70
79
|
// Whether the composer's send compacts first: the chip was showing and the
|
|
@@ -75,7 +84,7 @@ export function composerCompacts(
|
|
|
75
84
|
opts: { state: ChatState; thresholdTokens: number; asIs: boolean }
|
|
76
85
|
): boolean {
|
|
77
86
|
if (opts.asIs || text.trimStart().startsWith("/")) return false;
|
|
78
|
-
return !!
|
|
87
|
+
return !!compactionDue(context, opts);
|
|
79
88
|
}
|
|
80
89
|
|
|
81
90
|
// The chat's line for a compaction: "Compacted before sending · idle 47m ·
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Session } from "../../db";
|
|
2
2
|
import type { ChatStartOptions } from "../driver";
|
|
3
3
|
import { refreshSettings } from "../refresh";
|
|
4
|
+
import { sessionProviderEnv } from "../../models/launch";
|
|
4
5
|
|
|
5
6
|
export type RoleExtras = Pick<
|
|
6
7
|
ChatStartOptions,
|
|
@@ -17,11 +18,22 @@ export type RoleExtras = Pick<
|
|
|
17
18
|
// Start over on a new conversation once its context passes this many
|
|
18
19
|
// tokens (lib/chat/refresh); absent or 0, never.
|
|
19
20
|
refresh?: { atTokens: number; role: "orchestrator" | "task" };
|
|
21
|
+
// What the session's model provider needs in the agent's environment
|
|
22
|
+
// (lib/models), its key fetched just now. Held by this process only.
|
|
23
|
+
env?: Record<string, string>;
|
|
20
24
|
};
|
|
21
25
|
|
|
22
26
|
// What a session's role adds to its chat. An orchestrator's module loads
|
|
23
27
|
// only for one; a task's chat gets its brief, as its terminal agent would.
|
|
24
28
|
export async function roleExtras(session: Session): Promise<RoleExtras> {
|
|
29
|
+
const [extras, env] = await Promise.all([
|
|
30
|
+
ownExtras(session),
|
|
31
|
+
sessionProviderEnv(session),
|
|
32
|
+
]);
|
|
33
|
+
return env ? { ...extras, env } : extras;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function ownExtras(session: Session): Promise<RoleExtras> {
|
|
25
37
|
if (session.role === "orchestrator")
|
|
26
38
|
return (await import("../../orchestrator/worker")).orchestratorExtras(
|
|
27
39
|
session
|