feat(core,cli,server,web): add goal mode — loop Tasks on one Session until an objective completes (#66)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
rank-Yu
2026-07-27 23:43:24 +08:00
committed by GitHub
parent e1141ca010
commit 46463bee26
61 changed files with 3411 additions and 188 deletions
+48 -5
View File
@@ -48,6 +48,7 @@ import {
import {
buildContextSummaryText,
buildTurnAbortedBlock,
downgradeGoalInput,
extractSummary,
buildTurnRetriedBlock,
transcribeText,
@@ -264,6 +265,44 @@ class MergeQueue {
}
}
/**
* Rewrites a dead goal's round input before carry-over re-sends it.
*
* How such a message gets here: a goal round is interrupted (user stop, LLM failure,
* reconnect exhaustion) → the interruption also ends the whole goal → yet the engine still
* holds that round's input in pendingCarryOver and will prepend it to the NEXT task's
* request. Without this rewrite the model would receive the full protocol block as if it
* were current instructions and likely resume chasing the dead objective instead of the
* user's new task:
*
* [goal]
* round: 1
* This message was sent automatically by goal mode: work toward the objective …
* … GOAL.yaml path and status rules, completion/blocked audits …
* [/goal]
*
* make all tests pass
*
* The rewrite keeps the context but kills the instructions:
*
* [goal round 1 of an ended goal run — protocol omitted; do not act on it]
* make all tests pass
*
* Only user text that parses as a goal round is touched — tool outputs (the pairing
* carry-over), events, and plain user text pass through unchanged. Applied at the two
* carry-over CONSUMER sites (next-run input assembly, manual-compact summarize) rather
* than at each hold site, which also covers carry-over rebuilt by resume; the
* [turn_aborted] transcript path is handled separately at transcription time
* (buildTurnAbortedText). The downgrade itself lives in markers/goal-block.ts.
*/
function downgradeCarriedGoalInput(msg: OmniMessage): OmniMessage {
const p = msg.payload as { type?: string; role?: string; text?: string };
if (msg.type !== "model_msg" || p.type !== "text" || p.role !== "user" || !p.text) return msg;
const downgraded = downgradeGoalInput(p.text);
if (downgraded === p.text) return msg;
return { ...msg, payload: { ...msg.payload, text: downgraded } as OmniMessage["payload"] };
}
/**
* Delay before reconnect attempt N (1-based): exponential growth from `base` with a hard
* ceiling `max` — `min(base × 2^(N−1), max)`. With the defaults (250ms base, 30s ceiling,
@@ -405,7 +444,7 @@ export class ContextEngine {
// input, to form this Request's input.
const summary = this.pendingSummary;
this.pendingSummary = null;
const carryOver = this.pendingCarryOver;
const carryOver = this.pendingCarryOver.map(downgradeCarriedGoalInput);
this.pendingCarryOver = [];
const prefix = summary ? [summary, ...carryOver] : carryOver;
const input = prefix.length ? [...prefix, ...newMessages] : newMessages;
@@ -631,7 +670,11 @@ export class ContextEngine {
yield* this.discardContext("manual");
return;
}
const result = yield* this.summarizeContext("manual", this.pendingCarryOver, opts?.signal);
const result = yield* this.summarizeContext(
"manual",
this.pendingCarryOver.map(downgradeCarriedGoalInput),
opts?.signal,
);
if (result.status === "completed") {
this.pendingCarryOver = [];
this.pendingSummary = result.summary!;
@@ -1175,8 +1218,8 @@ export class ContextEngine {
* next run's first request (or the next manual compaction, which folds carry-over in) sends
* them ahead of everything else, completing the tool_use/tool_result pairing on the live
* LLM object that the provider would otherwise reject every subsequent request over. The
* repairs were already written to Trace at synthesis time, and carry-over is never rewritten
* at send time, so no duplicate Trace entries arise.
* repairs were already written to Trace at synthesis time, and carry-over is never re-written
* to Trace at send time, so no duplicate Trace entries arise.
*/
private stashRepairs(repairs: OmniMessage[]): void {
if (repairs.length === 0) return;
@@ -1408,7 +1451,7 @@ export class ContextEngine {
if (inner !== null) {
if (inner) lines.push(inner);
} else {
lines.push(transcribeUserInput(t));
lines.push(transcribeUserInput(downgradeGoalInput(t)));
}
}
lines.push(...transcribeTurnLines(assistantSegments, toolCalls, toolOutputs));