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:
@@ -31,6 +31,7 @@ import type { OmniMessage, TextPayload, ToolCallPayload } from "../src/omnimessa
|
||||
import { Environment } from "../src/environment/index.js";
|
||||
import { Writer, readTrace } from "../src/trace/index.js";
|
||||
import { ContextEngine, reconnectDelayMs } from "../src/engine/context-engine.js";
|
||||
import { goalRoundMessage } from "../src/goal/goal-prompts.js";
|
||||
import type { ApproveFn, EnvironmentInterface, ToolPermission } from "../src/interfaces.js";
|
||||
|
||||
/** Deterministic fake LLM: the first turn yields a tool_call, the second yields the final reply. */
|
||||
@@ -458,6 +459,104 @@ describe("ContextEngine ReAct loop (mock LLM, approve callback)", () => {
|
||||
expect(texts.join("\n")).not.toContain("[turn_aborted]");
|
||||
});
|
||||
|
||||
it("downgrades a goal round's protocol in the [turn_aborted] transcript (LLM failure path)", async () => {
|
||||
// An aborted/failed goal round's input rides into the next task via flatten carry-over;
|
||||
// its [goal] protocol ("the system sends the next round automatically", the file rules)
|
||||
// is stale the moment the goal ends and must not re-enter the model as live instructions.
|
||||
const goalInput = goalRoundMessage({
|
||||
objective: "fix the tests",
|
||||
goalFilePath: "/tmp/GOAL.yaml",
|
||||
round: 1,
|
||||
tokensUsed: 0,
|
||||
budget: -1,
|
||||
body: "fix the tests",
|
||||
});
|
||||
const received: OmniMessage[][] = [];
|
||||
let calls = 0;
|
||||
const llm: LLMInterface = {
|
||||
async *streamGenerate(params) {
|
||||
received.push(params.newMessages);
|
||||
if (++calls === 1) {
|
||||
yield partialText("start", "");
|
||||
yield partialText("delta", "half a thought");
|
||||
return { status: "failed", message: "boom" };
|
||||
}
|
||||
yield assistantText("ok");
|
||||
yield tokenUsage(emptyTokenCounts(), {
|
||||
cache_read: 0,
|
||||
cache_write: 0,
|
||||
output: 1,
|
||||
total: 1,
|
||||
});
|
||||
return { status: "completed" };
|
||||
},
|
||||
};
|
||||
const environment = new Environment({
|
||||
workspaceDir: workspace,
|
||||
toolConfig: execCommandToolConfig(),
|
||||
});
|
||||
const engine = new ContextEngine({ llm, environment });
|
||||
|
||||
await collectRun(engine, [userText(goalInput)], allowAll);
|
||||
await collectRun(engine, [userText("unrelated new task")], allowAll);
|
||||
|
||||
expect(received).toHaveLength(2);
|
||||
const texts = received[1]!.map((m) => (m.payload as { text?: string }).text ?? "");
|
||||
const joined = texts.join("\n");
|
||||
// The transcript survives (interrupted-work context), the protocol does not.
|
||||
expect(joined).toContain("[turn_aborted]");
|
||||
expect(joined).toContain("goal round 1 of an ended goal run");
|
||||
expect(joined).toContain("fix the tests");
|
||||
expect(joined).not.toContain("[goal]");
|
||||
expect(joined).not.toContain("Do not modify the goal file");
|
||||
expect(joined).toContain("unrelated new task");
|
||||
});
|
||||
|
||||
it("downgrades a goal round held raw in carry-over (pre-dispatch abort path)", async () => {
|
||||
// Aborted before the Request went out: the input is held AS-IS (not flattened) — without
|
||||
// the downgrade, the full [goal] block would be re-sent verbatim as current input.
|
||||
const goalInput = goalRoundMessage({
|
||||
objective: "fix the tests",
|
||||
goalFilePath: "/tmp/GOAL.yaml",
|
||||
round: 2,
|
||||
tokensUsed: 0,
|
||||
budget: -1,
|
||||
body: "fix the tests",
|
||||
});
|
||||
const received: OmniMessage[][] = [];
|
||||
const llm: LLMInterface = {
|
||||
async *streamGenerate(params) {
|
||||
received.push(params.newMessages);
|
||||
yield assistantText("ok");
|
||||
yield tokenUsage(emptyTokenCounts(), {
|
||||
cache_read: 0,
|
||||
cache_write: 0,
|
||||
output: 1,
|
||||
total: 1,
|
||||
});
|
||||
return { status: "completed" };
|
||||
},
|
||||
};
|
||||
const environment = new Environment({
|
||||
workspaceDir: workspace,
|
||||
toolConfig: execCommandToolConfig(),
|
||||
});
|
||||
const engine = new ContextEngine({ llm, environment });
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
await collectRun(engine, [userText(goalInput)], allowAll, controller.signal);
|
||||
await collectRun(engine, [userText("unrelated new task")], allowAll);
|
||||
|
||||
expect(received).toHaveLength(1);
|
||||
const texts = received[0]!.map((m) => (m.payload as { text?: string }).text ?? "");
|
||||
const joined = texts.join("\n");
|
||||
expect(joined).toContain("goal round 2 of an ended goal run");
|
||||
expect(joined).toContain("fix the tests");
|
||||
expect(joined).not.toContain("[goal]");
|
||||
expect(joined).toContain("unrelated new task");
|
||||
});
|
||||
|
||||
it("never writes the flatten carry-over to trace (case B): synthesized carry-over is memory-only", async () => {
|
||||
let call = 0;
|
||||
const llm: LLMInterface = {
|
||||
|
||||
Reference in New Issue
Block a user