pi-subagents 0.42.0 → 0.43.0
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/CHANGELOG.md +35 -0
- package/README.md +3 -5
- package/package.json +1 -1
- package/skills/pi-subagents/SKILL.md +2 -2
- package/skills/pi-subagents/references/constraints-and-recipes.md +35 -32
- package/skills/pi-subagents/references/execution-controls.md +27 -43
- package/skills/pi-subagents/references/management-authoring-rpc.md +20 -3
- package/skills/pi-subagents/references/prompting-and-roles.md +16 -49
- package/src/agents/agent-refinements.ts +624 -0
- package/src/agents/agents.ts +0 -2
- package/src/agents/proactive-skills.ts +1 -1
- package/src/api/delegation.ts +1 -2
- package/src/extension/control-notices.ts +2 -2
- package/src/extension/fanout-child.ts +46 -22
- package/src/extension/index.ts +29 -12
- package/src/extension/public-execution.ts +71 -0
- package/src/extension/rpc.ts +7 -8
- package/src/extension/schemas.ts +17 -17
- package/src/extension/tool-description.ts +14 -14
- package/src/missions/actions.ts +47 -11
- package/src/missions/goal-driver.ts +162 -0
- package/src/missions/lifecycle.ts +44 -12
- package/src/missions/store.ts +68 -3
- package/src/missions/types.ts +25 -3
- package/src/missions/workflow-state.ts +77 -0
- package/src/profiles/profiles.ts +1 -3
- package/src/runs/background/async-execution.ts +3 -0
- package/src/runs/background/async-job-tracker.ts +2 -17
- package/src/runs/background/control-channel.ts +50 -6
- package/src/runs/background/retained-children.ts +68 -0
- package/src/runs/background/scheduled-runs.ts +17 -13
- package/src/runs/background/steering.ts +7 -5
- package/src/runs/background/subagent-runner.ts +10 -4
- package/src/runs/foreground/async-steering-action.ts +36 -10
- package/src/runs/foreground/chain-clarify.ts +13 -16
- package/src/runs/foreground/execution.ts +4 -0
- package/src/runs/foreground/subagent-executor.ts +200 -59
- package/src/runs/shared/acceptance.ts +154 -9
- package/src/runs/shared/subagent-prompt-runtime.ts +94 -25
- package/src/shared/types.ts +17 -3
- package/src/slash/delegation-adapters.ts +0 -13
- package/src/slash/prompt-template-bridge.ts +14 -12
- package/src/slash/prompt-workflows.ts +10 -4
- package/src/slash/slash-bridge.ts +8 -6
- package/src/slash/slash-commands.ts +27 -12
- package/src/slash/slash-live-state.ts +4 -2
- package/src/tui/fleet-status.ts +8 -4
- package/src/tui/fleet.ts +15 -5
- package/src/tui/render.ts +13 -31
- package/src/workflows/chat-progress.ts +2 -2
- package/src/workflows/scripted-workflow.ts +97 -10
- package/agents/context-builder.md +0 -46
- package/agents/planner.md +0 -56
- package/prompts/parallel-context-build.md +0 -55
- package/prompts/parallel-handoff-plan.md +0 -61
package/src/missions/actions.ts
CHANGED
|
@@ -14,8 +14,10 @@ import {
|
|
|
14
14
|
type MissionStatus,
|
|
15
15
|
type MissionStoreConfig,
|
|
16
16
|
type MissionStoreLocation,
|
|
17
|
+
type MissionTokenBudget,
|
|
17
18
|
type MissionUpdateInput,
|
|
18
19
|
} from "./types.ts";
|
|
20
|
+
import { missionStatePath } from "./workflow-state.ts";
|
|
19
21
|
import {
|
|
20
22
|
createMission,
|
|
21
23
|
listGlobalMissions,
|
|
@@ -40,13 +42,17 @@ export type MissionAction = typeof MISSION_ACTIONS[number];
|
|
|
40
42
|
|
|
41
43
|
export interface MissionLaunchInput {
|
|
42
44
|
title: string;
|
|
43
|
-
|
|
45
|
+
objective?: string;
|
|
46
|
+
goal?: true;
|
|
47
|
+
budget?: MissionTokenBudget;
|
|
44
48
|
labels?: string[];
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
export interface MissionUpdateToolInput {
|
|
48
52
|
title?: string;
|
|
49
|
-
|
|
53
|
+
objective?: string;
|
|
54
|
+
goal?: boolean | { paused: boolean };
|
|
55
|
+
budget?: MissionTokenBudget;
|
|
50
56
|
status?: MissionStatus;
|
|
51
57
|
summary?: string;
|
|
52
58
|
labels?: string[];
|
|
@@ -101,18 +107,26 @@ export function validateMissionLaunch(value: unknown): MissionLaunchInput {
|
|
|
101
107
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("mission must be an object");
|
|
102
108
|
const input = value as Record<string, unknown>;
|
|
103
109
|
for (const key of Object.keys(input)) {
|
|
104
|
-
if (key !== "title" && key !== "summary" && key !== "goal" && key !== "labels") throw new Error(`mission.${key} is unknown`);
|
|
110
|
+
if (key !== "title" && key !== "summary" && key !== "objective" && key !== "goal" && key !== "budget" && key !== "labels") throw new Error(`mission.${key} is unknown`);
|
|
105
111
|
}
|
|
106
112
|
if (input.title !== undefined && input.summary !== undefined) throw new Error("mission.title and mission.summary cannot both be set");
|
|
107
113
|
const title = input.title ?? input.summary;
|
|
108
114
|
if (typeof title !== "string" || !title.trim()) throw new Error("mission.title or mission.summary must be a non-empty string");
|
|
109
|
-
if (input.
|
|
115
|
+
if (input.objective !== undefined && (typeof input.objective !== "string" || !input.objective.trim())) throw new Error("mission.objective must be a non-empty string");
|
|
116
|
+
if (input.goal !== undefined && input.goal !== true) throw new Error("mission.goal must be true when supplied");
|
|
117
|
+
const budget = input.budget;
|
|
118
|
+
if (budget !== undefined && (!budget || typeof budget !== "object" || Array.isArray(budget) || !Number.isSafeInteger((budget as { tokens?: unknown }).tokens) || ((budget as { tokens: number }).tokens < 1))) {
|
|
119
|
+
throw new Error("mission.budget.tokens must be a positive integer");
|
|
120
|
+
}
|
|
121
|
+
if (input.goal === true && budget === undefined) throw new Error("mission.budget is required when mission.goal is true");
|
|
110
122
|
if (input.labels !== undefined && (!Array.isArray(input.labels) || input.labels.some((label) => typeof label !== "string" || !label.trim()))) {
|
|
111
123
|
throw new Error("mission.labels must contain only non-empty strings");
|
|
112
124
|
}
|
|
113
125
|
return {
|
|
114
126
|
title: title.trim(),
|
|
115
|
-
...(input.
|
|
127
|
+
...(typeof input.objective === "string" ? { objective: input.objective.trim() } : {}),
|
|
128
|
+
...(input.goal === true ? { goal: true as const } : {}),
|
|
129
|
+
...(budget !== undefined ? { budget: { tokens: (budget as { tokens: number }).tokens } } : {}),
|
|
116
130
|
...(input.labels !== undefined ? { labels: input.labels.map((label) => label.trim()) } : {}),
|
|
117
131
|
};
|
|
118
132
|
}
|
|
@@ -163,15 +177,30 @@ function validateMissionUpdate(value: unknown): MissionUpdateInput {
|
|
|
163
177
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("missionUpdate must be an object");
|
|
164
178
|
const input = value as Record<string, unknown>;
|
|
165
179
|
for (const key of Object.keys(input)) {
|
|
166
|
-
if (!["title", "goal", "status", "summary", "labels", "artifacts", "receipts", "decisions"].includes(key)) throw new Error(`missionUpdate.${key} is unknown`);
|
|
180
|
+
if (!["title", "objective", "goal", "budget", "status", "summary", "labels", "artifacts", "receipts", "decisions"].includes(key)) throw new Error(`missionUpdate.${key} is unknown`);
|
|
167
181
|
}
|
|
168
182
|
const update: MissionUpdateInput = {};
|
|
169
|
-
for (const field of ["title", "
|
|
183
|
+
for (const field of ["title", "objective", "summary"] as const) {
|
|
170
184
|
const candidate = input[field];
|
|
171
185
|
if (candidate === undefined) continue;
|
|
172
186
|
if (typeof candidate !== "string" || !candidate.trim()) throw new Error(`missionUpdate.${field} must be a non-empty string`);
|
|
173
187
|
update[field] = candidate.trim();
|
|
174
188
|
}
|
|
189
|
+
if (input.goal !== undefined) {
|
|
190
|
+
if (typeof input.goal === "boolean") update.goal = input.goal ? { status: "active" } : false;
|
|
191
|
+
else {
|
|
192
|
+
if (!input.goal || typeof input.goal !== "object" || Array.isArray(input.goal) || typeof (input.goal as { paused?: unknown }).paused !== "boolean" || Object.keys(input.goal).some((key) => key !== "paused")) {
|
|
193
|
+
throw new Error("missionUpdate.goal must be boolean or { paused: boolean }");
|
|
194
|
+
}
|
|
195
|
+
update.goal = { status: (input.goal as { paused: boolean }).paused ? "paused" : "active" };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (input.budget !== undefined) {
|
|
199
|
+
if (!input.budget || typeof input.budget !== "object" || Array.isArray(input.budget) || !Number.isSafeInteger((input.budget as { tokens?: unknown }).tokens) || (input.budget as { tokens: number }).tokens < 1) {
|
|
200
|
+
throw new Error("missionUpdate.budget.tokens must be a positive integer");
|
|
201
|
+
}
|
|
202
|
+
update.budget = { tokens: (input.budget as { tokens: number }).tokens };
|
|
203
|
+
}
|
|
175
204
|
if (input.status !== undefined) update.status = validateStatus(input.status, "missionUpdate.status");
|
|
176
205
|
if (input.labels !== undefined) {
|
|
177
206
|
if (!Array.isArray(input.labels) || input.labels.some((label) => typeof label !== "string" || !label.trim())) throw new Error("missionUpdate.labels must contain only non-empty strings");
|
|
@@ -236,7 +265,11 @@ function refreshLinkedRunStatus(location: MissionStoreLocation, record: MissionR
|
|
|
236
265
|
if (updates.length === 0) return { record, warnings };
|
|
237
266
|
const merged = record.runs.map((run) => updates.find((update) => update.runId === run.runId && update.childIndex === run.childIndex) ?? run);
|
|
238
267
|
const states = merged.map((run) => run.status);
|
|
239
|
-
const status: MissionStatus =
|
|
268
|
+
const status: MissionStatus = record.status === "completed" || record.status === "failed" || record.status === "cancelled"
|
|
269
|
+
? record.status
|
|
270
|
+
: record.goal && !states.some((state) => state === "queued" || state === "running" || state === "active")
|
|
271
|
+
? "active"
|
|
272
|
+
: states.some((state) => state === "queued" || state === "running" || state === "active")
|
|
240
273
|
? "active"
|
|
241
274
|
: states.some((state) => state === "paused")
|
|
242
275
|
? "waiting"
|
|
@@ -255,9 +288,10 @@ function formatMission(record: MissionRecord): string {
|
|
|
255
288
|
`Mission: ${record.id}`,
|
|
256
289
|
`Title: ${record.title}`,
|
|
257
290
|
`Status: ${record.status}`,
|
|
258
|
-
`
|
|
291
|
+
`Objective: ${record.objective}`,
|
|
259
292
|
`Updated: ${record.updatedAt}`,
|
|
260
293
|
];
|
|
294
|
+
if (record.goal && record.budget) lines.push(`Goal mode: ${record.goal.status}`, `Budget: ${record.usage?.tokens ?? 0}/${record.budget.tokens} tokens`);
|
|
261
295
|
if (record.summary) lines.push(`Summary: ${record.summary}`);
|
|
262
296
|
if (record.labels?.length) lines.push(`Labels: ${record.labels.join(", ")}`);
|
|
263
297
|
if (record.runs.length) {
|
|
@@ -293,7 +327,9 @@ export function handleMissionAction(
|
|
|
293
327
|
const mission = validateMissionLaunch(params.mission);
|
|
294
328
|
const record = createMission(location, {
|
|
295
329
|
title: mission.title,
|
|
296
|
-
|
|
330
|
+
objective: mission.objective ?? mission.title,
|
|
331
|
+
...(mission.goal === true ? { goal: true as const } : {}),
|
|
332
|
+
...(mission.budget ? { budget: mission.budget } : {}),
|
|
297
333
|
status: params.missionStatus ? validateStatus(params.missionStatus, "missionStatus") : "planned",
|
|
298
334
|
...(mission.labels ? { labels: mission.labels } : {}),
|
|
299
335
|
...(ctx.currentSessionId ? { ownerSessionId: ctx.currentSessionId } : {}),
|
|
@@ -321,7 +357,7 @@ export function handleMissionAction(
|
|
|
321
357
|
}
|
|
322
358
|
if (action === "mission.show") {
|
|
323
359
|
const refreshed = refreshLinkedRunStatus(location, readMission(location, requireMissionId(params)));
|
|
324
|
-
const lines = [formatMission(refreshed.record)];
|
|
360
|
+
const lines = [formatMission(refreshed.record), `State: ${missionStatePath(location, refreshed.record.id)}`];
|
|
325
361
|
if (refreshed.warnings.length) lines.push("", ...refreshed.warnings.map((warning) => `Warning: ${warning}`));
|
|
326
362
|
return textResult(lines.join("\n"), {
|
|
327
363
|
mode: "management",
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { RetainedChild } from "../runs/background/retained-children.ts";
|
|
4
|
+
import type { ControlEvent } from "../shared/types.ts";
|
|
5
|
+
import { listMissions, readMission, updateMission } from "./store.ts";
|
|
6
|
+
import type { MissionRecord, MissionRunLink, MissionStoreLocation } from "./types.ts";
|
|
7
|
+
import { missionStatePath } from "./workflow-state.ts";
|
|
8
|
+
|
|
9
|
+
const TERMINAL_MISSION_STATUSES = new Set(["completed", "failed", "cancelled"]);
|
|
10
|
+
const ACTIVE_RUN_STATUSES = new Set(["queued", "running", "active"]);
|
|
11
|
+
const MAX_ACTION_LENGTH = 180;
|
|
12
|
+
|
|
13
|
+
export interface GoalContinuationNotice {
|
|
14
|
+
missionId: string;
|
|
15
|
+
message: string;
|
|
16
|
+
event: ControlEvent;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function bounded(value: string): string {
|
|
20
|
+
const normalized = value.replace(/\s+/g, " ").trim();
|
|
21
|
+
return normalized.length > MAX_ACTION_LENGTH ? `${normalized.slice(0, MAX_ACTION_LENGTH - 1)}…` : normalized;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function tokenUsage(value: unknown): number | undefined {
|
|
25
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
26
|
+
const total = (value as { total?: unknown }).total;
|
|
27
|
+
return Number.isSafeInteger(total) && (total as number) >= 0 ? total as number : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readLinkedRun(run: MissionRunLink): MissionRunLink {
|
|
31
|
+
if (!run.asyncDir) return run;
|
|
32
|
+
const statusPath = path.join(run.asyncDir, "status.json");
|
|
33
|
+
if (!fs.existsSync(statusPath)) return run;
|
|
34
|
+
const status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as Record<string, unknown>;
|
|
35
|
+
if (typeof status.state !== "string" || !status.state.trim()) throw new Error(`Linked run status '${statusPath}' is missing state`);
|
|
36
|
+
const tokens = tokenUsage(status.totalTokens)
|
|
37
|
+
?? (Array.isArray(status.steps)
|
|
38
|
+
? status.steps.reduce((total, step) => {
|
|
39
|
+
if (!step || typeof step !== "object") return total;
|
|
40
|
+
return total + (tokenUsage((step as { tokens?: unknown }).tokens) ?? 0);
|
|
41
|
+
}, 0)
|
|
42
|
+
: undefined);
|
|
43
|
+
return {
|
|
44
|
+
...run,
|
|
45
|
+
status: status.state,
|
|
46
|
+
...(!ACTIVE_RUN_STATUSES.has(status.state) && !run.completedAt ? { completedAt: new Date().toISOString() } : {}),
|
|
47
|
+
...(tokens !== undefined ? { usage: { tokens } } : {}),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function refreshGoalMission(location: MissionStoreLocation, record: MissionRecord): MissionRecord {
|
|
52
|
+
const runs = record.runs.map(readLinkedRun);
|
|
53
|
+
const changed = runs.some((run, index) => JSON.stringify(run) !== JSON.stringify(record.runs[index]));
|
|
54
|
+
if (!changed) return record;
|
|
55
|
+
const active = runs.some((run) => run.status && ACTIVE_RUN_STATUSES.has(run.status));
|
|
56
|
+
return updateMission(location, record.id, {
|
|
57
|
+
status: active ? "active" : record.goal ? "active" : record.status,
|
|
58
|
+
addRuns: runs,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function readyActionFromValue(value: unknown, pathLabel = "state", depth = 0): string | undefined {
|
|
63
|
+
if (depth > 8 || !value || typeof value !== "object") return undefined;
|
|
64
|
+
if (Array.isArray(value)) {
|
|
65
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
66
|
+
const found = readyActionFromValue(value[index], `${pathLabel}[${index}]`, depth + 1);
|
|
67
|
+
if (found) return found;
|
|
68
|
+
}
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
const input = value as Record<string, unknown>;
|
|
72
|
+
for (const key of ["nextReadyAction", "nextAction"] as const) {
|
|
73
|
+
if (typeof input[key] === "string" && input[key].trim()) return bounded(input[key]);
|
|
74
|
+
}
|
|
75
|
+
if (input.status === "ready") {
|
|
76
|
+
for (const key of ["action", "task", "title", "summary"] as const) {
|
|
77
|
+
if (typeof input[key] === "string" && input[key].trim()) return bounded(input[key]);
|
|
78
|
+
}
|
|
79
|
+
return `Continue ready mission state at ${pathLabel}`;
|
|
80
|
+
}
|
|
81
|
+
for (const [key, child] of Object.entries(input)) {
|
|
82
|
+
const found = readyActionFromValue(child, `${pathLabel}.${key}`, depth + 1);
|
|
83
|
+
if (found) return found;
|
|
84
|
+
}
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function missionStateAction(location: MissionStoreLocation, record: MissionRecord): string | undefined {
|
|
89
|
+
const statePath = missionStatePath(location, record.id);
|
|
90
|
+
if (fs.existsSync(statePath)) {
|
|
91
|
+
const action = readyActionFromValue(JSON.parse(fs.readFileSync(statePath, "utf-8")));
|
|
92
|
+
if (action) return action;
|
|
93
|
+
}
|
|
94
|
+
const decision = record.decisions.find((item) => item.status === "open");
|
|
95
|
+
if (decision) return bounded(decision.recommendation ?? `Resolve decision: ${decision.title}`);
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function retainedResumeTarget(record: MissionRecord, retainedChildren: RetainedChild[]): RetainedChild | undefined {
|
|
100
|
+
const latestRun = record.runs.at(-1);
|
|
101
|
+
if (!latestRun) return undefined;
|
|
102
|
+
return retainedChildren.find((child) => child.runId === latestRun.runId || child.parentRunId === latestRun.runId);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function nextReadyAction(location: MissionStoreLocation, record: MissionRecord, retainedChildren: RetainedChild[]): string {
|
|
106
|
+
const stateAction = missionStateAction(location, record);
|
|
107
|
+
const latestRun = record.runs.at(-1);
|
|
108
|
+
const action = stateAction
|
|
109
|
+
?? (latestRun?.status === "failed" || latestRun?.status === "paused"
|
|
110
|
+
? `Inspect linked run ${latestRun.runId} and continue the mission`
|
|
111
|
+
: `Continue objective: ${record.objective}`);
|
|
112
|
+
const retained = retainedResumeTarget(record, retainedChildren);
|
|
113
|
+
return retained
|
|
114
|
+
? `Resume retained child ${retained.runId} (${retained.agent}) for: ${bounded(action)}`
|
|
115
|
+
: bounded(action);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function collectGoalContinuationNotices(input: {
|
|
119
|
+
location: MissionStoreLocation;
|
|
120
|
+
ownerSessionId: string;
|
|
121
|
+
retainedChildren: RetainedChild[];
|
|
122
|
+
turnId: number;
|
|
123
|
+
now?: number;
|
|
124
|
+
}): GoalContinuationNotice[] {
|
|
125
|
+
const notices: GoalContinuationNotice[] = [];
|
|
126
|
+
const seen = new Set<string>();
|
|
127
|
+
for (const listed of listMissions(input.location).records) {
|
|
128
|
+
if (listed.ownerSessionId !== input.ownerSessionId || !listed.goal || TERMINAL_MISSION_STATUSES.has(listed.status)) continue;
|
|
129
|
+
let record = refreshGoalMission(input.location, readMission(input.location, listed.id));
|
|
130
|
+
if (!record.goal || record.goal.status !== "active" || !record.budget) continue;
|
|
131
|
+
if ((record.usage?.tokens ?? 0) >= record.budget.tokens) {
|
|
132
|
+
record = updateMission(input.location, record.id, { usage: record.usage ?? { tokens: 0 } });
|
|
133
|
+
if (record.goal?.status === "budget-exhausted") continue;
|
|
134
|
+
}
|
|
135
|
+
if (record.runs.some((run) => run.status && ACTIVE_RUN_STATUSES.has(run.status))) continue;
|
|
136
|
+
if (seen.has(record.id) || !record.budget) continue;
|
|
137
|
+
seen.add(record.id);
|
|
138
|
+
const budget = record.budget.tokens;
|
|
139
|
+
const used = record.usage?.tokens ?? 0;
|
|
140
|
+
const remaining = Math.max(0, budget - used);
|
|
141
|
+
const message = [
|
|
142
|
+
`Goal mission needs attention: ${bounded(record.title)}`,
|
|
143
|
+
`Mission: ${record.id}`,
|
|
144
|
+
`Remaining budget: ${remaining} tokens (${used}/${budget} used)`,
|
|
145
|
+
`Next ready action: ${nextReadyAction(input.location, record, input.retainedChildren)}`,
|
|
146
|
+
].join("\n");
|
|
147
|
+
notices.push({
|
|
148
|
+
missionId: record.id,
|
|
149
|
+
message,
|
|
150
|
+
event: {
|
|
151
|
+
type: "needs_attention",
|
|
152
|
+
to: "needs_attention",
|
|
153
|
+
ts: input.now ?? Date.now(),
|
|
154
|
+
runId: `goal-${record.id}-turn-${input.turnId}`,
|
|
155
|
+
agent: "goal mission",
|
|
156
|
+
message,
|
|
157
|
+
reason: "idle",
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return notices;
|
|
162
|
+
}
|
|
@@ -34,14 +34,21 @@ interface PersistedMissionBinding {
|
|
|
34
34
|
retainTerminal?: number;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
function
|
|
38
|
-
|
|
37
|
+
function workflowObjective(params: MissionLaunchParams): string | undefined {
|
|
38
|
+
const objective = params.task?.trim()
|
|
39
39
|
|| params.tasks?.find((task) => task.task?.trim())?.task?.trim()
|
|
40
40
|
|| params.chain?.find((step) => step.task?.trim())?.task?.trim();
|
|
41
|
+
if (objective) return objective;
|
|
42
|
+
for (const step of params.chain ?? []) {
|
|
43
|
+
const parallel = Array.isArray(step.parallel) ? step.parallel : step.parallel ? [step.parallel] : [];
|
|
44
|
+
const task = parallel.find((child) => child.task?.trim())?.task?.trim();
|
|
45
|
+
if (task) return task;
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
41
48
|
}
|
|
42
49
|
|
|
43
|
-
function conciseTitle(
|
|
44
|
-
const firstLine =
|
|
50
|
+
function conciseTitle(objective: string): string {
|
|
51
|
+
const firstLine = objective.split(/\r?\n/, 1)[0]?.trim() || objective.trim();
|
|
45
52
|
return firstLine.length > 100 ? `${firstLine.slice(0, 97)}...` : firstLine;
|
|
46
53
|
}
|
|
47
54
|
|
|
@@ -51,24 +58,27 @@ export function prepareMissionLaunch(input: {
|
|
|
51
58
|
config?: MissionStoreConfig;
|
|
52
59
|
ownerSessionId?: string;
|
|
53
60
|
}): MissionLaunchBinding | undefined {
|
|
54
|
-
|
|
61
|
+
const hasMissionId = input.params.missionId !== undefined;
|
|
62
|
+
if (hasMissionId && input.params.mission !== undefined) throw new Error("Use missionId or mission, not both");
|
|
55
63
|
if (input.params.mission === false) return undefined;
|
|
56
|
-
const
|
|
64
|
+
const objective = workflowObjective(input.params);
|
|
57
65
|
const missionsEnabled = input.config?.enabled !== false;
|
|
58
|
-
const shouldCreate = input.params.mission !== undefined || (missionsEnabled &&
|
|
59
|
-
if (!
|
|
66
|
+
const shouldCreate = input.params.mission !== undefined || (missionsEnabled && objective !== undefined);
|
|
67
|
+
if (!hasMissionId && !shouldCreate) return undefined;
|
|
60
68
|
const location = resolveMissionStoreLocation({ projectRoot: input.projectRoot, ...(input.config ? { config: input.config } : {}) });
|
|
61
|
-
if (
|
|
69
|
+
if (hasMissionId) {
|
|
62
70
|
const missionId = validateMissionId(input.params.missionId);
|
|
63
71
|
readMission(location, missionId);
|
|
64
72
|
updateMission(location, missionId, { status: "active" });
|
|
65
73
|
return { missionId, location, autoCreated: false, announceInContent: true };
|
|
66
74
|
}
|
|
67
75
|
const mission = input.params.mission !== undefined ? validateMissionLaunch(input.params.mission) : undefined;
|
|
68
|
-
const title = mission?.title || conciseTitle(
|
|
76
|
+
const title = mission?.title || conciseTitle(objective!);
|
|
69
77
|
const record = createMission(location, {
|
|
70
78
|
title,
|
|
71
|
-
|
|
79
|
+
objective: mission?.objective || objective || title,
|
|
80
|
+
...(mission?.goal === true ? { goal: true as const } : {}),
|
|
81
|
+
...(mission?.budget ? { budget: mission.budget } : {}),
|
|
72
82
|
status: "active",
|
|
73
83
|
...(mission?.labels ? { labels: mission.labels } : {}),
|
|
74
84
|
...(input.ownerSessionId ? { ownerSessionId: input.ownerSessionId } : {}),
|
|
@@ -92,7 +102,9 @@ function runStatusForResult(result: AgentToolResult<Details>): string {
|
|
|
92
102
|
}
|
|
93
103
|
|
|
94
104
|
function missionStatusForRun(record: MissionRecord, runId: string, runStatus: string): MissionStatus {
|
|
105
|
+
if (record.status === "completed" || record.status === "failed" || record.status === "cancelled") return record.status;
|
|
95
106
|
if (runStatus === "active" || runStatus === "queued" || runStatus === "running") return "active";
|
|
107
|
+
if (record.goal) return "active";
|
|
96
108
|
if (runStatus === "paused") return "waiting";
|
|
97
109
|
const otherActive = record.runs.some((run) => run.runId !== runId && (run.status === "active" || run.status === "queued" || run.status === "running"));
|
|
98
110
|
if (otherActive) return "active";
|
|
@@ -101,6 +113,17 @@ function missionStatusForRun(record: MissionRecord, runId: string, runStatus: st
|
|
|
101
113
|
return "failed";
|
|
102
114
|
}
|
|
103
115
|
|
|
116
|
+
function usageForResult(result: AgentToolResult<Details>): { tokens: number } | undefined {
|
|
117
|
+
const tokens = result.details.results.reduce((total, child) => total + child.usage.input + child.usage.output, 0);
|
|
118
|
+
return tokens > 0 ? { tokens } : undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function usageFromUnknown(value: unknown): { tokens: number } | undefined {
|
|
122
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
123
|
+
const total = (value as { total?: unknown }).total;
|
|
124
|
+
return Number.isSafeInteger(total) && (total as number) >= 0 ? { tokens: total as number } : undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
104
127
|
function artifactsForResult(result: AgentToolResult<Details>): MissionArtifact[] {
|
|
105
128
|
const artifacts: MissionArtifact[] = [];
|
|
106
129
|
if (result.details.asyncDir) {
|
|
@@ -168,6 +191,7 @@ export function attachMissionToLaunchResult(input: {
|
|
|
168
191
|
const runStatus = runStatusForResult(input.result);
|
|
169
192
|
const current = readMission(input.binding.location, input.binding.missionId);
|
|
170
193
|
const startedAt = new Date().toISOString();
|
|
194
|
+
const usage = usageForResult(input.result);
|
|
171
195
|
const run: MissionRunLink = {
|
|
172
196
|
runId,
|
|
173
197
|
mode: missionRunModeForResult(input.result.details.mode),
|
|
@@ -176,6 +200,7 @@ export function attachMissionToLaunchResult(input: {
|
|
|
176
200
|
...(input.result.details.asyncDir ? { asyncDir: input.result.details.asyncDir } : {}),
|
|
177
201
|
...(input.result.details.results.length === 1 && input.result.details.results[0]?.agent ? { agent: input.result.details.results[0].agent } : {}),
|
|
178
202
|
...(runStatus !== "active" ? { completedAt: startedAt } : {}),
|
|
203
|
+
...(usage ? { usage } : {}),
|
|
179
204
|
};
|
|
180
205
|
let mission = updateMission(input.binding.location, input.binding.missionId, {
|
|
181
206
|
status: missionStatusForRun(current, runId, runStatus),
|
|
@@ -305,9 +330,16 @@ export function syncMissionFromAsyncCompletion(value: unknown): MissionRecord |
|
|
|
305
330
|
}
|
|
306
331
|
}
|
|
307
332
|
const summary = typeof event.summary === "string" && event.summary.trim() ? event.summary.slice(0, 2000) : undefined;
|
|
333
|
+
const usage = usageFromUnknown(event.totalTokens)
|
|
334
|
+
?? (Array.isArray(event.results)
|
|
335
|
+
? { tokens: event.results.reduce((total, result) => {
|
|
336
|
+
if (!result || typeof result !== "object") return total;
|
|
337
|
+
return total + (usageFromUnknown((result as { tokens?: unknown }).tokens)?.tokens ?? 0);
|
|
338
|
+
}, 0) }
|
|
339
|
+
: undefined);
|
|
308
340
|
return updateMission(binding.location, binding.missionId, {
|
|
309
341
|
status: missionStatusForRun(current, runId, runStatus),
|
|
310
|
-
addRuns: [{ runId, mode: typeof event.mode === "string" && ["single", "parallel", "chain"].includes(event.mode) ? event.mode as SubagentRunMode : "external", asyncDir: event.asyncDir, status: runStatus, completedAt }],
|
|
342
|
+
addRuns: [{ runId, mode: typeof event.mode === "string" && ["single", "parallel", "chain"].includes(event.mode) ? event.mode as SubagentRunMode : "external", asyncDir: event.asyncDir, status: runStatus, completedAt, ...(usage && usage.tokens > 0 ? { usage } : {}) }],
|
|
311
343
|
addArtifacts: artifacts,
|
|
312
344
|
...(summary ? { summary } : {}),
|
|
313
345
|
});
|
package/src/missions/store.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type MissionArtifactKind,
|
|
13
13
|
type MissionCreateInput,
|
|
14
14
|
type MissionDecision,
|
|
15
|
+
type MissionGoal,
|
|
15
16
|
type MissionIndexEntry,
|
|
16
17
|
type MissionListResult,
|
|
17
18
|
type MissionReceipt,
|
|
@@ -23,6 +24,8 @@ import {
|
|
|
23
24
|
type MissionStatus,
|
|
24
25
|
type MissionStoreConfig,
|
|
25
26
|
type MissionStoreLocation,
|
|
27
|
+
type MissionTokenBudget,
|
|
28
|
+
type MissionTokenUsage,
|
|
26
29
|
type MissionUpdateInput,
|
|
27
30
|
} from "./types.ts";
|
|
28
31
|
|
|
@@ -63,6 +66,37 @@ function missionStatus(value: unknown, label: string): MissionStatus {
|
|
|
63
66
|
return value as MissionStatus;
|
|
64
67
|
}
|
|
65
68
|
|
|
69
|
+
function positiveTokenCount(value: unknown, label: string): number {
|
|
70
|
+
if (!Number.isSafeInteger(value) || (value as number) < 1) throw new Error(`${label} must be a positive integer`);
|
|
71
|
+
return value as number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function nonNegativeTokenCount(value: unknown, label: string): number {
|
|
75
|
+
if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error(`${label} must be a non-negative integer`);
|
|
76
|
+
return value as number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseStoredGoal(value: unknown, label: string): { goal?: MissionGoal; legacyObjective?: string } {
|
|
80
|
+
if (typeof value === "string") return { legacyObjective: requiredString(value, label).trim() };
|
|
81
|
+
return { goal: parseGoal(value, label) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function parseGoal(value: unknown, label: string): MissionGoal {
|
|
85
|
+
const input = asObject(value, label);
|
|
86
|
+
if (input.status !== "active" && input.status !== "paused" && input.status !== "budget-exhausted") throw new Error(`${label}.status is invalid`);
|
|
87
|
+
return { status: input.status };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function parseBudget(value: unknown, label: string): MissionTokenBudget {
|
|
91
|
+
const input = asObject(value, label);
|
|
92
|
+
return { tokens: positiveTokenCount(input.tokens, `${label}.tokens`) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function parseUsage(value: unknown, label: string): MissionTokenUsage {
|
|
96
|
+
const input = asObject(value, label);
|
|
97
|
+
return { tokens: nonNegativeTokenCount(input.tokens, `${label}.tokens`) };
|
|
98
|
+
}
|
|
99
|
+
|
|
66
100
|
function stringArray(value: unknown, label: string): string[] {
|
|
67
101
|
if (!Array.isArray(value)) throw new Error(`${label} must be an array of non-empty strings`);
|
|
68
102
|
const result = value.map((item, index) => requiredString(item, `${label}[${index}]`).trim());
|
|
@@ -94,6 +128,7 @@ function parseRunLink(value: unknown, label: string): MissionRunLink {
|
|
|
94
128
|
...(optionalString(input.status, `${label}.status`) ? { status: input.status as string } : {}),
|
|
95
129
|
...(input.startedAt !== undefined ? { startedAt: timestamp(input.startedAt, `${label}.startedAt`) } : {}),
|
|
96
130
|
...(input.completedAt !== undefined ? { completedAt: timestamp(input.completedAt, `${label}.completedAt`) } : {}),
|
|
131
|
+
...(input.usage !== undefined ? { usage: parseUsage(input.usage, `${label}.usage`) } : {}),
|
|
97
132
|
};
|
|
98
133
|
}
|
|
99
134
|
|
|
@@ -158,11 +193,20 @@ export function parseMissionRecord(value: unknown, source = "mission record"): M
|
|
|
158
193
|
const decisions = input.decisions as unknown[];
|
|
159
194
|
const artifacts = input.artifacts as unknown[];
|
|
160
195
|
const receipts = (input.receipts ?? []) as unknown[];
|
|
196
|
+
const parsedGoal = input.goal !== undefined ? parseStoredGoal(input.goal, `${source}.goal`) : {};
|
|
197
|
+
const budget = input.budget !== undefined ? parseBudget(input.budget, `${source}.budget`) : undefined;
|
|
198
|
+
const usage = input.usage !== undefined ? parseUsage(input.usage, `${source}.usage`) : undefined;
|
|
199
|
+
const objective = optionalString(input.objective, `${source}.objective`)?.trim() ?? parsedGoal.legacyObjective;
|
|
200
|
+
if (!objective) throw new Error(`${source}.objective must be a non-empty string`);
|
|
201
|
+
if (parsedGoal.goal && !budget) throw new Error(`${source}.budget is required for a goal mission`);
|
|
161
202
|
return {
|
|
162
203
|
schemaVersion: 1,
|
|
163
204
|
id: validateMissionId(input.id, `${source}.id`),
|
|
164
205
|
title: requiredString(input.title, `${source}.title`),
|
|
165
|
-
|
|
206
|
+
objective,
|
|
207
|
+
...(parsedGoal.goal ? { goal: parsedGoal.goal } : {}),
|
|
208
|
+
...(budget ? { budget } : {}),
|
|
209
|
+
...(usage ? { usage } : {}),
|
|
166
210
|
status: missionStatus(input.status, `${source}.status`),
|
|
167
211
|
createdAt: timestamp(input.createdAt, `${source}.createdAt`),
|
|
168
212
|
updatedAt: timestamp(input.updatedAt, `${source}.updatedAt`),
|
|
@@ -279,6 +323,7 @@ function pruneTerminalMissions(location: MissionStoreLocation, maxTerminal: numb
|
|
|
279
323
|
for (const record of terminal.slice(maxTerminal)) {
|
|
280
324
|
try {
|
|
281
325
|
fs.rmSync(missionRecordPath(location, record.id), { force: true });
|
|
326
|
+
fs.rmSync(path.join(location.missionDir, record.id), { recursive: true, force: true });
|
|
282
327
|
if (location.writeGlobalIndex) fs.rmSync(indexPath(location, record), { force: true });
|
|
283
328
|
} catch {
|
|
284
329
|
// Retention is best-effort and must never block a launch.
|
|
@@ -292,7 +337,10 @@ export function createMission(location: MissionStoreLocation, input: MissionCrea
|
|
|
292
337
|
schemaVersion: 1,
|
|
293
338
|
id: randomUUID(),
|
|
294
339
|
title: requiredString(input.title, "mission.title").trim(),
|
|
295
|
-
|
|
340
|
+
objective: requiredString(input.objective, "mission.objective").trim(),
|
|
341
|
+
...(input.goal === true ? { goal: { status: "active" as const } } : {}),
|
|
342
|
+
...(input.budget ? { budget: parseBudget(input.budget, "mission.budget") } : {}),
|
|
343
|
+
...(input.goal === true ? { usage: { tokens: 0 } } : {}),
|
|
296
344
|
status: input.status ?? "planned",
|
|
297
345
|
createdAt,
|
|
298
346
|
updatedAt: createdAt,
|
|
@@ -304,6 +352,7 @@ export function createMission(location: MissionStoreLocation, input: MissionCrea
|
|
|
304
352
|
...(input.ownerSessionId ? { ownerSessionId: requiredString(input.ownerSessionId, "mission.ownerSessionId") } : {}),
|
|
305
353
|
...(input.labels ? { labels: stringArray(input.labels, "mission.labels") } : {}),
|
|
306
354
|
};
|
|
355
|
+
if (input.goal === true && !input.budget) throw new Error("mission.budget is required when mission.goal is true");
|
|
307
356
|
const created = writeMission(location, record);
|
|
308
357
|
pruneTerminalMissions(location, retainTerminal);
|
|
309
358
|
return created;
|
|
@@ -390,6 +439,19 @@ export function updateMission(location: MissionStoreLocation, missionId: string,
|
|
|
390
439
|
...(decision.recommendation ? { recommendation: requiredString(decision.recommendation, "mission.update.addDecisions[].recommendation") } : {}),
|
|
391
440
|
})),
|
|
392
441
|
];
|
|
442
|
+
const budget = update.budget !== undefined ? parseBudget(update.budget, "mission.update.budget") : current.budget;
|
|
443
|
+
const usage = update.usage !== undefined
|
|
444
|
+
? parseUsage(update.usage, "mission.update.usage")
|
|
445
|
+
: { tokens: runs.reduce((total, run) => total + (run.usage?.tokens ?? 0), 0) };
|
|
446
|
+
let goal = update.goal === false ? undefined : update.goal !== undefined ? parseGoal(update.goal, "mission.update.goal") : current.goal;
|
|
447
|
+
if (goal && !budget) throw new Error("mission.update.budget is required when enabling a goal mission");
|
|
448
|
+
if (goal && budget) {
|
|
449
|
+
goal = usage.tokens >= budget.tokens
|
|
450
|
+
? { status: "budget-exhausted" }
|
|
451
|
+
: goal.status === "budget-exhausted"
|
|
452
|
+
? { status: "active" }
|
|
453
|
+
: goal;
|
|
454
|
+
}
|
|
393
455
|
const next: MissionRecord = {
|
|
394
456
|
...current,
|
|
395
457
|
updatedAt: createdAt,
|
|
@@ -398,12 +460,15 @@ export function updateMission(location: MissionStoreLocation, missionId: string,
|
|
|
398
460
|
receipts,
|
|
399
461
|
decisions,
|
|
400
462
|
...(update.title !== undefined ? { title: requiredString(update.title, "mission.update.title").trim() } : {}),
|
|
401
|
-
...(update.
|
|
463
|
+
...(update.objective !== undefined ? { objective: requiredString(update.objective, "mission.update.objective").trim() } : {}),
|
|
464
|
+
...(budget ? { budget } : {}),
|
|
465
|
+
...(goal ? { goal, usage } : {}),
|
|
402
466
|
...(update.status !== undefined ? { status: missionStatus(update.status, "mission.update.status") } : {}),
|
|
403
467
|
...(update.summary !== undefined ? { summary: requiredString(update.summary, "mission.update.summary") } : {}),
|
|
404
468
|
...(update.labels !== undefined ? { labels: stringArray(update.labels, "mission.update.labels") } : {}),
|
|
405
469
|
...(update.acceptance !== undefined ? { acceptance: update.acceptance } : {}),
|
|
406
470
|
};
|
|
471
|
+
if (!goal) delete next.goal;
|
|
407
472
|
const updated = writeMission(location, next);
|
|
408
473
|
if (TERMINAL_MISSION_STATUSES.has(updated.status)) pruneTerminalMissions(location, retainTerminal);
|
|
409
474
|
return updated;
|
package/src/missions/types.ts
CHANGED
|
@@ -13,6 +13,19 @@ export type MissionRunMode = "single" | "parallel" | "chain" | "workflow" | "sch
|
|
|
13
13
|
export type MissionArtifactKind = "status" | "output" | "patch" | "manifest" | "review" | "note" | "other";
|
|
14
14
|
export type MissionReceiptKind = "pull_request" | "ci" | "deployment" | "release";
|
|
15
15
|
export type MissionReceiptStatus = "pending" | "ready" | "succeeded" | "failed";
|
|
16
|
+
export type MissionGoalStatus = "active" | "paused" | "budget-exhausted";
|
|
17
|
+
|
|
18
|
+
export interface MissionGoal {
|
|
19
|
+
status: MissionGoalStatus;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface MissionTokenBudget {
|
|
23
|
+
tokens: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface MissionTokenUsage {
|
|
27
|
+
tokens: number;
|
|
28
|
+
}
|
|
16
29
|
|
|
17
30
|
export interface MissionRunLink {
|
|
18
31
|
runId: string;
|
|
@@ -23,6 +36,7 @@ export interface MissionRunLink {
|
|
|
23
36
|
status?: string;
|
|
24
37
|
startedAt?: string;
|
|
25
38
|
completedAt?: string;
|
|
39
|
+
usage?: MissionTokenUsage;
|
|
26
40
|
}
|
|
27
41
|
|
|
28
42
|
export interface MissionDecision {
|
|
@@ -56,7 +70,10 @@ export interface MissionRecord {
|
|
|
56
70
|
schemaVersion: 1;
|
|
57
71
|
id: string;
|
|
58
72
|
title: string;
|
|
59
|
-
|
|
73
|
+
objective: string;
|
|
74
|
+
goal?: MissionGoal;
|
|
75
|
+
budget?: MissionTokenBudget;
|
|
76
|
+
usage?: MissionTokenUsage;
|
|
60
77
|
status: MissionStatus;
|
|
61
78
|
createdAt: string;
|
|
62
79
|
updatedAt: string;
|
|
@@ -115,7 +132,9 @@ export interface GlobalMissionListResult {
|
|
|
115
132
|
|
|
116
133
|
export interface MissionCreateInput {
|
|
117
134
|
title: string;
|
|
118
|
-
|
|
135
|
+
objective: string;
|
|
136
|
+
goal?: boolean;
|
|
137
|
+
budget?: MissionTokenBudget;
|
|
119
138
|
status?: MissionStatus;
|
|
120
139
|
labels?: string[];
|
|
121
140
|
ownerSessionId?: string;
|
|
@@ -123,7 +142,10 @@ export interface MissionCreateInput {
|
|
|
123
142
|
|
|
124
143
|
export interface MissionUpdateInput {
|
|
125
144
|
title?: string;
|
|
126
|
-
|
|
145
|
+
objective?: string;
|
|
146
|
+
goal?: MissionGoal | false;
|
|
147
|
+
budget?: MissionTokenBudget;
|
|
148
|
+
usage?: MissionTokenUsage;
|
|
127
149
|
status?: MissionStatus;
|
|
128
150
|
summary?: string;
|
|
129
151
|
labels?: string[];
|