@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,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A provider's key, fetched from where its reference points at the moment
|
|
3
|
+
* an agent launches, and handed straight to that agent's environment. It is
|
|
4
|
+
* never written anywhere: not the database, not a log, not a prompt. Every
|
|
5
|
+
* key fetched is remembered in memory only so redact() can scrub it from
|
|
6
|
+
* any text on its way to a log or an error.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execFile } from "child_process";
|
|
10
|
+
import { shellQuote } from "../hosts/ssh";
|
|
11
|
+
import type { SecretRef } from "./providers";
|
|
12
|
+
|
|
13
|
+
export type SecretCommand = (cmd: string, args: string[]) => Promise<string>;
|
|
14
|
+
|
|
15
|
+
const run: SecretCommand = (cmd, args) =>
|
|
16
|
+
new Promise((resolve, reject) =>
|
|
17
|
+
execFile(cmd, args, { timeout: 15_000 }, (err, stdout) =>
|
|
18
|
+
// Its stderr isn't passed on: a tool may echo what it was asked.
|
|
19
|
+
err ? reject(new Error(`${cmd} failed`)) : resolve(stdout)
|
|
20
|
+
)
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
// An environment variable a key may be read from: one named for an API key,
|
|
24
|
+
// never AgentOS's own nor its Claude login's, so a reference can't lift an
|
|
25
|
+
// unrelated secret out of the server's environment.
|
|
26
|
+
export const KEY_ENV_NAME =
|
|
27
|
+
/^(?!(?:ANTHROPIC|CLAUDE|AGENTOS)_)[A-Z][A-Z0-9_]*_API_KEY$/;
|
|
28
|
+
|
|
29
|
+
// The command that prints the key, as argv; null for an environment
|
|
30
|
+
// variable, which is read directly.
|
|
31
|
+
export function secretCommand(ref: SecretRef): string[] | null {
|
|
32
|
+
if (ref.kind === "tokenvault")
|
|
33
|
+
return ["tv", "get", ref.project, ...(ref.desc ? [ref.desc] : [])];
|
|
34
|
+
if (ref.kind === "keychain")
|
|
35
|
+
return [
|
|
36
|
+
"security",
|
|
37
|
+
"find-generic-password",
|
|
38
|
+
"-s",
|
|
39
|
+
ref.service,
|
|
40
|
+
...(ref.account ? ["-a", ref.account] : []),
|
|
41
|
+
"-w",
|
|
42
|
+
];
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The same, as shell text that expands to the key inside the agent's own
|
|
47
|
+
// shell (a terminal launch), so AgentOS never holds it at all.
|
|
48
|
+
export function secretShellExpansion(ref: SecretRef): string {
|
|
49
|
+
const argv = secretCommand(ref);
|
|
50
|
+
if (!argv) return `"\${${ref.kind === "env" ? envName(ref.name) : ""}}"`;
|
|
51
|
+
return `"$(${argv.map(shellQuote).join(" ")})"`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function envName(name: string): string {
|
|
55
|
+
if (!KEY_ENV_NAME.test(name))
|
|
56
|
+
throw new Error(
|
|
57
|
+
`A key can't be read from ${name}: name a *_API_KEY variable`
|
|
58
|
+
);
|
|
59
|
+
return name;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const seen = new Set<string>();
|
|
63
|
+
|
|
64
|
+
export async function resolveSecret(
|
|
65
|
+
ref: SecretRef,
|
|
66
|
+
exec: SecretCommand = run
|
|
67
|
+
): Promise<string> {
|
|
68
|
+
const argv = secretCommand(ref);
|
|
69
|
+
let value: string | undefined;
|
|
70
|
+
if (argv) {
|
|
71
|
+
const out = await exec(argv[0], argv.slice(1)).catch(() => {
|
|
72
|
+
throw new Error(`Couldn't read the key (${argv[0]} failed)`);
|
|
73
|
+
});
|
|
74
|
+
value = out.trim();
|
|
75
|
+
} else if (ref.kind === "env") value = process.env[envName(ref.name)]?.trim();
|
|
76
|
+
if (!value) throw new Error("The key reference resolved to nothing");
|
|
77
|
+
if (value.length >= 8) seen.add(value);
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Scrubs every key fetched so far out of the text.
|
|
82
|
+
export function redact(text: string): string {
|
|
83
|
+
let out = text;
|
|
84
|
+
for (const s of seen) out = out.split(s).join("[redacted]");
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Moving a session to another model without losing its conversation or
|
|
3
|
+
* worktree. On the same provider a live conversation switches in place;
|
|
4
|
+
* across providers the endpoint and key are in the agent's environment, so
|
|
5
|
+
* its worker goes and the next message starts one that resumes the same
|
|
6
|
+
* conversation on the new provider.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { db, type Session } from "../db";
|
|
10
|
+
import { registry } from "../chat/registry";
|
|
11
|
+
import { setChatModel } from "../chat/settings";
|
|
12
|
+
import { setSessionModel, type RoutedModel } from "./resolve";
|
|
13
|
+
import { ANTHROPIC } from "./roles";
|
|
14
|
+
|
|
15
|
+
export async function switchSessionModel(
|
|
16
|
+
sessionId: string,
|
|
17
|
+
routed: RoutedModel
|
|
18
|
+
): Promise<void> {
|
|
19
|
+
const before = db
|
|
20
|
+
.prepare(`SELECT model_provider FROM sessions WHERE id = ?`)
|
|
21
|
+
.get(sessionId) as Pick<Session, "model_provider"> | undefined;
|
|
22
|
+
setSessionModel(sessionId, routed);
|
|
23
|
+
await applySessionModel(
|
|
24
|
+
sessionId,
|
|
25
|
+
before?.model_provider ?? ANTHROPIC,
|
|
26
|
+
routed
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// What's live catches up with the row: the model now, or a fresh worker.
|
|
31
|
+
export async function applySessionModel(
|
|
32
|
+
sessionId: string,
|
|
33
|
+
fromProvider: string,
|
|
34
|
+
routed: RoutedModel
|
|
35
|
+
): Promise<void> {
|
|
36
|
+
if (fromProvider === routed.provider.id)
|
|
37
|
+
return setChatModel(sessionId, routed.model);
|
|
38
|
+
if (registry.live.has(sessionId))
|
|
39
|
+
(await import("../chat/runner")).stopChat(sessionId);
|
|
40
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Test provider": one tiny request to a provider's endpoint with its key,
|
|
3
|
+
* reporting whether it answered. The key goes only into the request's
|
|
4
|
+
* header; what comes back is scrubbed of it before anyone sees it.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { runClaude, type ClaudeRunner } from "../orchestrator/claude-cli";
|
|
8
|
+
import { effectiveModels } from "./config";
|
|
9
|
+
import type { ModelProvider } from "./providers";
|
|
10
|
+
import { ANTHROPIC } from "./roles";
|
|
11
|
+
import { redact, resolveSecret, type SecretCommand } from "./secrets";
|
|
12
|
+
import os from "os";
|
|
13
|
+
|
|
14
|
+
export interface ProviderTest {
|
|
15
|
+
ok: boolean;
|
|
16
|
+
message: string;
|
|
17
|
+
ms: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const PROMPT = "Reply with the word OK.";
|
|
21
|
+
|
|
22
|
+
export async function testProvider(
|
|
23
|
+
providerId: string,
|
|
24
|
+
model?: string,
|
|
25
|
+
deps: {
|
|
26
|
+
exec?: SecretCommand;
|
|
27
|
+
fetch?: typeof fetch;
|
|
28
|
+
claude?: ClaudeRunner;
|
|
29
|
+
} = {}
|
|
30
|
+
): Promise<ProviderTest> {
|
|
31
|
+
const started = Date.now();
|
|
32
|
+
const done = (ok: boolean, message: string): ProviderTest => ({
|
|
33
|
+
ok,
|
|
34
|
+
message: redact(message).slice(0, 300),
|
|
35
|
+
ms: Date.now() - started,
|
|
36
|
+
});
|
|
37
|
+
const p = effectiveModels().providers.find((x) => x.id === providerId);
|
|
38
|
+
if (!p) return done(false, `No provider "${providerId}"`);
|
|
39
|
+
const m = model || p.tiers?.haiku || p.models[0];
|
|
40
|
+
if (!m) return done(false, "It lists no model to try");
|
|
41
|
+
try {
|
|
42
|
+
if (p.id === ANTHROPIC) {
|
|
43
|
+
await (deps.claude ?? runClaude)({
|
|
44
|
+
cwd: os.tmpdir(),
|
|
45
|
+
system: "Answer in the schema.",
|
|
46
|
+
prompt: PROMPT,
|
|
47
|
+
schema: {
|
|
48
|
+
type: "object",
|
|
49
|
+
properties: { ok: { type: "boolean" } },
|
|
50
|
+
required: ["ok"],
|
|
51
|
+
},
|
|
52
|
+
tools: [],
|
|
53
|
+
model: m,
|
|
54
|
+
timeoutMs: 60_000,
|
|
55
|
+
});
|
|
56
|
+
return done(true, `${m} answered through the Claude login`);
|
|
57
|
+
}
|
|
58
|
+
if (!p.baseUrl || !p.auth) return done(false, "Set its base URL and key");
|
|
59
|
+
const key = await resolveSecret(p.auth, deps.exec);
|
|
60
|
+
const res = await (deps.fetch ?? fetch)(...request(p, m, key));
|
|
61
|
+
// Never followed: a redirect would carry the key somewhere else.
|
|
62
|
+
if (res.status >= 300 && res.status < 400)
|
|
63
|
+
return done(
|
|
64
|
+
false,
|
|
65
|
+
`HTTP ${res.status}: the endpoint redirected; set its final URL`
|
|
66
|
+
);
|
|
67
|
+
if (res.ok) return done(true, `${m} answered (HTTP ${res.status})`);
|
|
68
|
+
const body = (await res.text().catch(() => ""))
|
|
69
|
+
.slice(0, 200)
|
|
70
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ");
|
|
71
|
+
return done(false, `HTTP ${res.status}: ${body}`);
|
|
72
|
+
} catch (e) {
|
|
73
|
+
return done(false, e instanceof Error ? e.message : String(e));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function request(
|
|
78
|
+
p: ModelProvider,
|
|
79
|
+
model: string,
|
|
80
|
+
key: string
|
|
81
|
+
): [string, RequestInit] {
|
|
82
|
+
const base = p.baseUrl!.replace(/\/+$/, "");
|
|
83
|
+
const messages = [{ role: "user", content: PROMPT }];
|
|
84
|
+
const signal = AbortSignal.timeout(30_000);
|
|
85
|
+
const redirect = "manual" as const;
|
|
86
|
+
if (p.api === "openai")
|
|
87
|
+
return [
|
|
88
|
+
`${base}/chat/completions`,
|
|
89
|
+
{
|
|
90
|
+
method: "POST",
|
|
91
|
+
redirect,
|
|
92
|
+
signal,
|
|
93
|
+
headers: {
|
|
94
|
+
"content-type": "application/json",
|
|
95
|
+
authorization: `Bearer ${key}`,
|
|
96
|
+
},
|
|
97
|
+
body: JSON.stringify({ model, max_tokens: 8, messages }),
|
|
98
|
+
},
|
|
99
|
+
];
|
|
100
|
+
return [
|
|
101
|
+
`${base}/v1/messages`,
|
|
102
|
+
{
|
|
103
|
+
method: "POST",
|
|
104
|
+
redirect,
|
|
105
|
+
signal,
|
|
106
|
+
headers: {
|
|
107
|
+
"content-type": "application/json",
|
|
108
|
+
"anthropic-version": "2023-06-01",
|
|
109
|
+
...(p.authHeader === "x-api-key"
|
|
110
|
+
? { "x-api-key": key }
|
|
111
|
+
: { authorization: `Bearer ${key}` }),
|
|
112
|
+
},
|
|
113
|
+
body: JSON.stringify({ model, max_tokens: 8, messages }),
|
|
114
|
+
},
|
|
115
|
+
];
|
|
116
|
+
}
|
package/lib/orchestrator/act.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* the brakes.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { parseOverride, type ModelOverride } from "../models/resolve";
|
|
7
8
|
import { sendMessage } from "../bus";
|
|
8
9
|
import { chatState, interruptChat } from "../chat/runner";
|
|
9
10
|
import { hostExec, isRemoteHost } from "../hosts";
|
|
@@ -80,9 +81,17 @@ export async function startTask(
|
|
|
80
81
|
workspaceId: string,
|
|
81
82
|
projectRef: string,
|
|
82
83
|
prompt: string,
|
|
83
|
-
opts: {
|
|
84
|
+
opts: {
|
|
85
|
+
base?: string;
|
|
86
|
+
name?: string;
|
|
87
|
+
view?: StartView;
|
|
88
|
+
after?: string;
|
|
89
|
+
model?: ModelOverride;
|
|
90
|
+
plan?: boolean;
|
|
91
|
+
} = {}
|
|
84
92
|
): Promise<string> {
|
|
85
93
|
const project = workspaceProject(workspaceId, projectRef);
|
|
94
|
+
const model = parseOverride(opts.model) ?? undefined;
|
|
86
95
|
// Its brakes read this machine's usage, not the other machine's account.
|
|
87
96
|
if (isRemoteHost(project.host_id))
|
|
88
97
|
throw new Error(
|
|
@@ -97,6 +106,7 @@ export async function startTask(
|
|
|
97
106
|
prompt,
|
|
98
107
|
name: opts.name,
|
|
99
108
|
view,
|
|
109
|
+
model,
|
|
100
110
|
baseBranch: opts.base || undefined,
|
|
101
111
|
after: opts.after,
|
|
102
112
|
originWorkspaceId: workspaceId,
|
|
@@ -112,6 +122,8 @@ export async function startTask(
|
|
|
112
122
|
name: opts.name,
|
|
113
123
|
baseBranch: opts.base || undefined,
|
|
114
124
|
view,
|
|
125
|
+
model,
|
|
126
|
+
plan: opts.plan,
|
|
115
127
|
}),
|
|
116
128
|
(s) => s.id
|
|
117
129
|
)
|
|
@@ -126,8 +126,7 @@ function fingerprint(
|
|
|
126
126
|
(f.task?.ciSettleIn ?? 0) > 0,
|
|
127
127
|
getCheck(task.id, head, "review")?.status,
|
|
128
128
|
getCheck(task.id, head, "scope")?.status,
|
|
129
|
-
pr.
|
|
130
|
-
pr.codeReview?.attribution,
|
|
129
|
+
pr.attribution,
|
|
131
130
|
f.task?.blocked,
|
|
132
131
|
f.status,
|
|
133
132
|
f.needsInput,
|
|
@@ -41,16 +41,16 @@ You may act without asking on anything inside this workspace that's additive or
|
|
|
41
41
|
Merge only through the gates: \`arm\` or \`sign_off\`. \`sign_off\` merges a task's PR, with the project's merge method (squash unless set otherwise), only if all of these hold, and otherwise refuses naming the gate:
|
|
42
42
|
- ci: CI is green on the PR's head commit and has settled (the commit is 2 minutes old and no new check has appeared for 2 minutes). A repository with no CI goes to Saad.
|
|
43
43
|
- review: an independent review of that exact commit passed. Run \`review\` on the task; it starts a fresh read-only reviewer in the background and its verdict reaches you as an event. A new commit needs a new review. The reviewer judges the PR against the task's brief plus every scope change you recorded on it with \`send\` and scope_change; a "Scope change" note in the PR body counts only where one of those backs it. When Saad changes a running task's scope, always tell the task that way.
|
|
44
|
-
- code-review:
|
|
44
|
+
- code-review: a passing review (the gate above) covers that exact commit, or the commit AgentOS restacked or rebased into it while the head is still the one AgentOS left, and the PR body carries no AI attribution. Tasks don't review themselves or write a "Code review" section any more: your review is the one review, so don't ask a task for one.
|
|
45
45
|
- blocked: no BLOCKED: line and no approval or question waiting in the task's chat or terminal.
|
|
46
46
|
- scope: the diff stays in the task's repository, adds no secrets, isn't only lockfiles, and, for a task from a card, a check against the card (run with the review) says it's within what the card asks.
|
|
47
47
|
- stack: a stacked task's parent has merged.
|
|
48
48
|
|
|
49
|
-
PRs AgentOS didn't open (dispatch on another machine, a person, dependabot) go through the same \`sign_off\` and \`review\`: name one by #N, owner/repo#N or its URL. It must be open, in the repository of one of this workspace's projects (anything else is refused), and not from a fork. It's an external PR: there's no task or session behind it, so the gates read it differently. blocked: a draft isn't ready yet, and a label like "blocked", "do not merge" or "wip" holds it. code-review:
|
|
49
|
+
PRs AgentOS didn't open (dispatch on another machine, a person, dependabot) go through the same \`sign_off\` and \`review\`: name one by #N, owner/repo#N or its URL. It must be open, in the repository of one of this workspace's projects (anything else is refused), and not from a fork. It's an external PR: there's no task or session behind it, so the gates read it differently. blocked: a draft isn't ready yet, and a label like "blocked", "do not merge" or "wip" holds it. code-review: your \`review\` of that exact head is what counts, as for a task; a "Code review" section in its body is ignored. ci, review, scope, Saad's approvals and his hold are the same as for a task, and a stack never applies. A PR that turns out to be a task's is gated as that task.
|
|
50
50
|
|
|
51
51
|
Arm a task once its work is what you want merged, rather than calling \`sign_off\` and waiting: \`arm\` makes AgentOS merge it the moment every gate passes at its head (it starts the review itself once CI is green), and you hear one event, "merged" or the failure. A failure counts like sign_off's, once per head, and the task stays armed for the fix; one that goes to Saad disarms it, as does an open ask_saad about that task (it merges on his answer). Don't poll an armed task with sign_off. \`disarm\` takes it back.
|
|
52
52
|
|
|
53
|
-
When the base moves and a task's PR conflicts with it, AgentOS rebases it onto the base by itself if that's clean and its agent is idle with nothing uncommitted, pushes only the task's own branch, and tells you with one event.
|
|
53
|
+
When the base moves and a task's PR conflicts with it, AgentOS rebases it onto the base by itself if that's clean and its agent is idle with nothing uncommitted, pushes only the task's own branch, and tells you with one event. Your review of the old head carries over when the PR's own patch is unchanged (a fresh one starts otherwise). If it conflicts, the task is asked once to rebase and you get one event; don't send it the same request.
|
|
54
54
|
|
|
55
55
|
Tasks are woken by AgentOS itself when a check fails on their PR's head (with the failing step and the log around the error) and when a collaborator reviews or comments on their PR, and you get one "task X woken: ..." line for each. Don't message a task about the same failure or review; act only if it doesn't fix it.
|
|
56
56
|
|
|
@@ -92,7 +92,8 @@ Reading:
|
|
|
92
92
|
Acting:
|
|
93
93
|
- \`${TOOL_NAMES.send}\` (session, message, scope_change?): message a session; it arrives as its next prompt. scope_change records it on the task's brief for its review.
|
|
94
94
|
- \`${TOOL_NAMES.message_orchestrator}\` (workspace, message): message another workspace's orchestrator, the only thing you reach outside this workspace. Use it to hand over a bug or ask for something in its projects; it decides what to do there.
|
|
95
|
-
- \`${TOOL_NAMES.start_task}\` (project, prompt, base?, name?, view?, after?): a task in its own worktree that ends in a PR. Write the prompt as a full brief; name it in 2-6 words, or it's named from the prompt. It runs as a chat; pass view terminal only when the job needs a TUI. Pass after (a task's id or name, or "any") to hold it until that task finishes, instead of remembering it in your notes. Over the workspace's running task limit it's queued too: the result says "Queued (position N)", it starts by itself, and you get an event when it does.
|
|
95
|
+
- \`${TOOL_NAMES.start_task}\` (project, prompt, base?, name?, view?, after?, model?, plan?): a task in its own worktree that ends in a PR. Write the prompt as a full brief; name it in 2-6 words, or it's named from the prompt. It runs as a chat; pass view terminal only when the job needs a TUI. Pass after (a task's id or name, or "any") to hold it until that task finishes, instead of remembering it in your notes. Over the workspace's running task limit it's queued too: the result says "Queued (position N)", it starts by itself, and you get an event when it does. Its models follow the role settings (it plans on the planning model, then implements on the implementation one); pass model ("provider/model", or a map of role to one) only when Saad asks for one, and another provider than anthropic only then.
|
|
96
|
+
- \`${TOOL_NAMES.set_task_model}\` (task, model, message?): move a paused task to another model and carry on in the same conversation and worktree; it keeps that model from then on. Only when Saad asks.
|
|
96
97
|
- \`${TOOL_NAMES.start_session}\` (project, prompt, name?, view?): an interactive session, a chat unless view is terminal.
|
|
97
98
|
- \`${TOOL_NAMES.stack}\` (target, plan_only?): run a board's open cards as stacked tasks; plan_only shows the plan without starting.
|
|
98
99
|
- \`${TOOL_NAMES.land}\` (id): merge a stack bottom-up through the gates.
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { spawn } from "child_process";
|
|
12
|
+
import { runUsageOf, type RunUsage } from "../usage/check-runs";
|
|
13
|
+
import { routedRun } from "../models/launch";
|
|
14
|
+
import type { ModelRole } from "../models/roles";
|
|
15
|
+
import { redact } from "../models/secrets";
|
|
12
16
|
|
|
13
17
|
export interface ClaudeRun {
|
|
14
18
|
cwd: string;
|
|
@@ -20,14 +24,18 @@ export interface ClaudeRun {
|
|
|
20
24
|
// Permission rules within those, e.g. "Bash(git diff:*)".
|
|
21
25
|
allow?: string[];
|
|
22
26
|
timeoutMs?: number;
|
|
23
|
-
// A model alias or id
|
|
27
|
+
// A model alias or id on Anthropic; the role's model otherwise.
|
|
24
28
|
model?: string;
|
|
29
|
+
// What the run cost, from its result, even when it failed.
|
|
30
|
+
onUsage?: (usage: RunUsage) => void;
|
|
31
|
+
// The kind of work it is, which picks its model and provider
|
|
32
|
+
// (lib/models): Settings' and the workspace's role models.
|
|
33
|
+
role?: ModelRole;
|
|
34
|
+
workspaceId?: string | null;
|
|
25
35
|
}
|
|
26
36
|
|
|
27
37
|
export type ClaudeRunner = (run: ClaudeRun) => Promise<unknown>;
|
|
28
38
|
|
|
29
|
-
const MODEL = process.env.AGENTOS_REVIEW_MODEL;
|
|
30
|
-
|
|
31
39
|
// All a check's process gets from the server's environment: enough to run
|
|
32
40
|
// and to sign in to Claude, nothing else.
|
|
33
41
|
const ENV_ALLOW = [
|
|
@@ -54,7 +62,7 @@ export function checkEnv(
|
|
|
54
62
|
}
|
|
55
63
|
|
|
56
64
|
export function claudeArgs(run: ClaudeRun): string[] {
|
|
57
|
-
const model = run.model
|
|
65
|
+
const model = run.model;
|
|
58
66
|
return [
|
|
59
67
|
"-p",
|
|
60
68
|
"--output-format",
|
|
@@ -84,12 +92,35 @@ export function claudeArgs(run: ClaudeRun): string[] {
|
|
|
84
92
|
];
|
|
85
93
|
}
|
|
86
94
|
|
|
95
|
+
interface ClaudeResult {
|
|
96
|
+
is_error?: boolean;
|
|
97
|
+
result?: string;
|
|
98
|
+
structured_output?: unknown;
|
|
99
|
+
}
|
|
100
|
+
|
|
87
101
|
// The structured answer, or throws with what went wrong.
|
|
88
|
-
export const runClaude: ClaudeRunner = (run) =>
|
|
89
|
-
|
|
102
|
+
export const runClaude: ClaudeRunner = async (run) => {
|
|
103
|
+
const routed =
|
|
104
|
+
run.role && !run.model
|
|
105
|
+
? await routedRun(run.role, { workspaceId: run.workspaceId })
|
|
106
|
+
: null;
|
|
107
|
+
const result = await execClaude(
|
|
108
|
+
{ ...run, model: run.model ?? routed?.model },
|
|
109
|
+
{ ...checkEnv(), ...routed?.env },
|
|
110
|
+
routed?.provider ?? "anthropic"
|
|
111
|
+
);
|
|
112
|
+
return result.structured_output;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
function execClaude(
|
|
116
|
+
run: ClaudeRun,
|
|
117
|
+
env: NodeJS.ProcessEnv,
|
|
118
|
+
provider: string
|
|
119
|
+
): Promise<ClaudeResult> {
|
|
120
|
+
return new Promise((resolve, reject) => {
|
|
90
121
|
const child = spawn("claude", claudeArgs(run), {
|
|
91
122
|
cwd: run.cwd,
|
|
92
|
-
env
|
|
123
|
+
env,
|
|
93
124
|
stdio: ["pipe", "pipe", "pipe"],
|
|
94
125
|
});
|
|
95
126
|
let out = "";
|
|
@@ -110,18 +141,21 @@ export const runClaude: ClaudeRunner = (run) =>
|
|
|
110
141
|
child.on("close", () => {
|
|
111
142
|
clearTimeout(timer);
|
|
112
143
|
try {
|
|
113
|
-
const result = JSON.parse(out) as
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
structured_output?: unknown;
|
|
117
|
-
};
|
|
144
|
+
const result = JSON.parse(out) as ClaudeResult;
|
|
145
|
+
const usage = runUsageOf(result);
|
|
146
|
+
if (usage) run.onUsage?.({ ...usage, provider });
|
|
118
147
|
if (result.is_error || result.structured_output === undefined)
|
|
119
148
|
throw new Error(result.result || "No answer");
|
|
120
|
-
resolve(result
|
|
149
|
+
resolve(result);
|
|
121
150
|
} catch (e) {
|
|
122
151
|
const why = e instanceof Error ? e.message : String(e);
|
|
123
|
-
reject(
|
|
152
|
+
reject(
|
|
153
|
+
new Error(
|
|
154
|
+
redact(`claude -p failed: ${why} ${err.slice(-300)}`.trim())
|
|
155
|
+
)
|
|
156
|
+
);
|
|
124
157
|
}
|
|
125
158
|
});
|
|
126
159
|
child.stdin.end(run.prompt);
|
|
127
160
|
});
|
|
161
|
+
}
|
package/lib/orchestrator/diff.ts
CHANGED
|
@@ -93,7 +93,7 @@ const SENSITIVE: { why: string; test: RegExp }[] = [
|
|
|
93
93
|
// What merges check a PR's code review with: a change that weakens it
|
|
94
94
|
// must not merge through it.
|
|
95
95
|
why: "the code review gate",
|
|
96
|
-
test: /^
|
|
96
|
+
test: /^lib\/tasks\/code-review\.ts$/i,
|
|
97
97
|
},
|
|
98
98
|
{
|
|
99
99
|
why: "secrets handling",
|
|
@@ -12,11 +12,11 @@
|
|
|
12
12
|
|
|
13
13
|
import { MERGED_AS, mergePolicy } from "../tasks/merge-policy";
|
|
14
14
|
import { mergePR } from "../tasks/merge-pr";
|
|
15
|
-
import { attributionIn,
|
|
15
|
+
import { attributionIn, codeReviewGate } from "../tasks/code-review";
|
|
16
16
|
import { run } from "../tasks/gh";
|
|
17
17
|
import { refundApproval, spendApproval } from "./ask-approvals";
|
|
18
18
|
import { resolveAsks } from "./asks";
|
|
19
|
-
import { clearStaleRunning, getCheck
|
|
19
|
+
import { clearStaleRunning, getCheck } from "./checks";
|
|
20
20
|
import { gateTarget, type ExternalPR } from "./external-pr";
|
|
21
21
|
import { holdingEscalations } from "./gates";
|
|
22
22
|
import { addNote } from "./notes";
|
|
@@ -39,44 +39,6 @@ const refsOf = (ext: ExternalPR) => ({
|
|
|
39
39
|
branch_name: ext.branch,
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
-
// The code-review gate: a section in the body must cover the head like a
|
|
43
|
-
// task's; with none, the independent review of the head stands in.
|
|
44
|
-
export function externalCodeReview(
|
|
45
|
-
ext: Pick<ExternalPR, "name" | "body" | "pr">,
|
|
46
|
-
sha: string,
|
|
47
|
-
review: CheckRow | null
|
|
48
|
-
): { refusal: string | null; wait: string | null } {
|
|
49
|
-
const attribution = attributionIn(ext.body);
|
|
50
|
-
if (attribution)
|
|
51
|
-
return {
|
|
52
|
-
refusal: `the PR body carries AI attribution (${attribution}): remove it with \`gh pr edit --body-file\``,
|
|
53
|
-
wait: null,
|
|
54
|
-
};
|
|
55
|
-
const section = ext.pr.codeReview;
|
|
56
|
-
if (section !== null)
|
|
57
|
-
return {
|
|
58
|
-
refusal: codeReviewRefusal(
|
|
59
|
-
section,
|
|
60
|
-
sha,
|
|
61
|
-
null,
|
|
62
|
-
`${ext.name}'s "Code review" section has to name ${short(sha)} on a "Reviewed:" line, or come out of the body`
|
|
63
|
-
),
|
|
64
|
-
wait: null,
|
|
65
|
-
};
|
|
66
|
-
const none = `${ext.name}'s body has no "Code review" section; for a PR AgentOS didn't open, the orchestrator's own review of ${short(sha)} stands in for it`;
|
|
67
|
-
if (review?.sha === sha && review.status === "pass")
|
|
68
|
-
return { refusal: null, wait: null };
|
|
69
|
-
return {
|
|
70
|
-
refusal: null,
|
|
71
|
-
wait:
|
|
72
|
-
review?.sha === sha && review.status === "running"
|
|
73
|
-
? `${none}, and it's still running`
|
|
74
|
-
: review?.sha === sha && review.status === "block"
|
|
75
|
-
? `${none}, and it has blocking findings`
|
|
76
|
-
: `${none}: call review`,
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
|
|
80
42
|
export async function judgeExternal(
|
|
81
43
|
workspaceId: string,
|
|
82
44
|
ext: ExternalPR,
|
|
@@ -102,7 +64,6 @@ export async function judgeExternal(
|
|
|
102
64
|
},
|
|
103
65
|
async () => {
|
|
104
66
|
const review = getCheck(ext.id, sha, "review");
|
|
105
|
-
const code = externalCodeReview(ext, sha, review);
|
|
106
67
|
const label = ext.labels.find((l) => BLOCKED_LABEL.test(l));
|
|
107
68
|
return {
|
|
108
69
|
checks: pr.checks,
|
|
@@ -124,8 +85,12 @@ export async function judgeExternal(
|
|
|
124
85
|
fromCard: false,
|
|
125
86
|
scope: null,
|
|
126
87
|
stackRefusal: null,
|
|
127
|
-
|
|
128
|
-
|
|
88
|
+
// The same gate as a task's: the review of this exact head.
|
|
89
|
+
codeReview: codeReviewGate({
|
|
90
|
+
head: sha,
|
|
91
|
+
attribution: attributionIn(ext.body) ?? null,
|
|
92
|
+
review: (at) => getCheck(ext.id, at, "review"),
|
|
93
|
+
}),
|
|
129
94
|
};
|
|
130
95
|
},
|
|
131
96
|
opts
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { db } from "../db";
|
|
14
|
+
import type { CodeReviewGate } from "../tasks/code-review";
|
|
14
15
|
import type { ChecksVerdict } from "../tasks/state";
|
|
15
16
|
import type { CheckRow } from "./checks";
|
|
16
17
|
import { APPROVAL_GATES, mergeApprovalsOn } from "./merge-approvals";
|
|
@@ -45,11 +46,9 @@ export interface GateInput {
|
|
|
45
46
|
fromCard: boolean;
|
|
46
47
|
scope: CheckRow | null;
|
|
47
48
|
stackRefusal: string | null;
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
// of this commit stands in for the section).
|
|
52
|
-
codeReviewWait?: string | null;
|
|
49
|
+
// Whether a passing independent review covers this commit, and the body
|
|
50
|
+
// carries no AI attribution (tasks/code-review.ts).
|
|
51
|
+
codeReview: CodeReviewGate;
|
|
53
52
|
// A PR no task owns has no session to read: a draft isn't ready yet, and
|
|
54
53
|
// a label saying it's blocked holds it.
|
|
55
54
|
prDraft?: string | null;
|
|
@@ -147,11 +146,12 @@ export function evaluateGates(i: GateInput): GateOutcome[] {
|
|
|
147
146
|
const stack: GateOutcome = i.stackRefusal
|
|
148
147
|
? { gate: "stack", state: "fail", reason: i.stackRefusal }
|
|
149
148
|
: { gate: "stack", state: "pass" };
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
149
|
+
// Its wait is the review gate's own (no passing review of this exact
|
|
150
|
+
// head), which already holds the merge: only paths without that gate (an
|
|
151
|
+
// approval, a stack's land) read it.
|
|
152
|
+
const codeReview: GateOutcome = i.codeReview.refusal
|
|
153
|
+
? { gate: "code-review", state: "fail", reason: i.codeReview.refusal }
|
|
154
|
+
: { gate: "code-review", state: "pass" };
|
|
155
155
|
return [
|
|
156
156
|
ciGate(i),
|
|
157
157
|
checkGate("review", i.review, i.sha),
|
package/lib/orchestrator/home.ts
CHANGED
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
* sessions.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { resolveRoleOrDefault } from "../models/resolve";
|
|
8
9
|
import fs from "fs";
|
|
9
10
|
import os from "os";
|
|
10
11
|
import path from "path";
|
|
11
12
|
import { randomBytes, randomUUID, timingSafeEqual } from "crypto";
|
|
12
13
|
import { db, type Session } from "../db";
|
|
13
|
-
import { resolveModelForAgent } from "../model-catalog";
|
|
14
14
|
import { getWorkspace } from "../workspaces";
|
|
15
15
|
|
|
16
16
|
// It works through tools, not in a repo: a scratch folder of its own.
|
|
@@ -62,7 +62,7 @@ export function ensureOrchestrator(workspaceId: string): Session {
|
|
|
62
62
|
`${workspace.name} orchestrator`,
|
|
63
63
|
`claude-${id}`,
|
|
64
64
|
dir,
|
|
65
|
-
|
|
65
|
+
resolveRoleOrDefault("orchestrator", { workspaceId }).model,
|
|
66
66
|
workspaceId
|
|
67
67
|
);
|
|
68
68
|
return getOrchestrator(workspaceId)!;
|
|
@@ -272,7 +272,7 @@ export function checkMessage(
|
|
|
272
272
|
: pr.failingUrl
|
|
273
273
|
? `\n\nIts details: ${untrusted("CI", pr.failingUrl)}`
|
|
274
274
|
: "";
|
|
275
|
-
return `CI check ${untrusted("CI", pr.failing ?? "")} failed on your PR #${pr.number} at ${short(pr.head)}${step}.${tail}\n\n${UNTRUSTED_RULE}\n\nFix the root cause (not the check), re-run your checks,
|
|
275
|
+
return `CI check ${untrusted("CI", pr.failing ?? "")} failed on your PR #${pr.number} at ${short(pr.head)}${step}.${tail}\n\n${UNTRUSTED_RULE}\n\nFix the root cause (not the check), re-run your checks, then push.`;
|
|
276
276
|
}
|
|
277
277
|
|
|
278
278
|
// True once the PR's activity at this fingerprint has been handled.
|
|
@@ -374,7 +374,7 @@ export function reviewMessage(pr: TaskPR, events: PREvent[]): string {
|
|
|
374
374
|
: "";
|
|
375
375
|
return `- ${e.author} ${what}${body}${notes}${more}`;
|
|
376
376
|
});
|
|
377
|
-
return `New review activity on your PR #${pr.number}:\n${items.join("\n")}\n\n${UNTRUSTED_RULE} Address each point: fix what's right, re-run your checks,
|
|
377
|
+
return `New review activity on your PR #${pr.number}:\n${items.join("\n")}\n\n${UNTRUSTED_RULE} Address each point: fix what's right, re-run your checks, then push. Where you disagree, reply on the PR (\`gh pr comment ${pr.number}\`) saying why.`;
|
|
378
378
|
}
|
|
379
379
|
|
|
380
380
|
// Tests only.
|