Ride the session goal on every outgoing turn instead of behind a tool call

4fa44449b875 · AtlantisPleb · · parent 7aba5407981e

Ride the session goal on every outgoing turn instead of behind a tool call

A session's goal is standing state, but the only path from the goal store
to the model was goal(action='get') — so a session opening with "hi"
spent its first tool round discovering it had no goal, and a session
with a goal set could take every turn without ever seeing the objective
(OpenAgentsInc/openagents#60).

The goal now reaches the model the way standing context does: CoderSession's
turn composition appends goalContinuationPrompt for an active goal and
goalBudgetExhaustedPrompt for a budget_limited one, so the objective,
status, and remaining budget accompany the prompt without being asked
for. A paused, completed, or blocked goal injects nothing.

With no goal set, nothing about goals is declared: declareTools() in the
CLI includes goalTool only when the store holds a goal, and because the
tool list is re-declared on every turn through capability retrieval, the
tool appears on the turn after /goal sets one.

The get action is gone from the tool — reading state the model already
holds is not a decision. What remains is the half that genuinely is:
complete, block, pause, resume, the model reporting a state change.

goalContinuationPrompt and goalBudgetExhaustedPrompt leave the uncalled
production symbol baseline by being called, and the guard confirms the
shrunken ledger.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoZMfWRSGnf6FZX2Ar9rQ2
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified packages/openagents-cli/src/cli.ts
  • modified packages/openagents-cli/src/coder-goals.ts
  • modified packages/openagents-cli/src/coder-session.ts
  • modified packages/openagents-cli/test/coder-goals.test.ts
  • modified packages/openagents-cli/test/coder-session.test.ts
  • modified scripts/uncalled-production-symbol-baseline.json

Diff

6 files changed, +138 -35

packages/openagents-cli/src/cli.ts modified +6 -1

@@ -2582,7 +2582,12 @@ const coderCommand = Command.make(

2582 2582
          shellTool(process.cwd()),
2583 2583
          ...(active.length === 0 ? [] : [skillTool(active)]),
2584 2584
          openagentsTool(),
2585
          goalTool(goalStore),
2585
          // The goal tool follows the goal: a session with no goal declares
2586
          // nothing about goals, and the objective itself reaches the model by
2587
          // riding the outgoing turn (OpenAgentsInc/openagents#60), not by
2588
          // being asked for. Re-declaration per turn is what makes the tool
2589
          // appear the turn after `/goal` sets one.
2590
          ...(goalStore.getGoal() === undefined ? [] : [goalTool(goalStore)]),
2586 2591
          ...(setup === undefined ? [] : [delegateTool(setup.delegation)]),
2587 2592
          capability,
2588 2593
          ...visiblePlugins().map((plugin) => {
packages/openagents-cli/src/coder-goals.ts modified +19 -21

@@ -6,7 +6,10 @@

6 6
 * - Supports `/goal <objective>`, `/goal status`, `/goal clear`, `/goal pause`, `/goal resume`.
7 7
 * - Easter egg: `/goooooal` with arbitrary repeated `o`s resolves to `/goal`.
8 8
 * - Tracks status, token budget, tokens used, elapsed time, and turn progression.
9
 * - Exposes `goal` tool to the agent model (`get`, `update`, `complete`, `block`).
9
 * - Exposes `goal` tool to the agent model for reporting state changes
10
 *   (`complete`, `block`, `pause`, `resume`). Reading the goal is not an
11
 *   action: the active objective rides every outgoing turn via the
12
 *   continuation prompts below, so the model already holds it.
10 13
 * - Generates continuation prompts and budget exhaustion prompts for multi-turn alignment.
11 14
 */
12 15

@@ -211,19 +214,29 @@ export function goalBudgetExhaustedPrompt(goal: PersistentGoal): string {

211 214
  ].join("\n");
212 215
}
213 216
214
/** Model tool allowing the agent to inspect or complete its goal */
217
/**
218
 * Model tool for reporting goal state changes.
219
 *
220
 * There is deliberately no `get`: the goal's objective, status, and remaining
221
 * budget already ride every outgoing turn, so reading state the model holds
222
 * would only spend a tool round rediscovering it
223
 * (OpenAgentsInc/openagents#60). What remains are the genuine model
224
 * decisions — reporting that the goal finished, hit a wall, or should pause
225
 * or resume.
226
 */
215 227
export function goalTool(goalStore: GoalStore): CoderTool {
216 228
  return {
217 229
    name: "goal",
218 230
    description:
219
      "Inspect, update, or complete the active persistent task goal for this session. " +
220
      "Call this with action='get' to inspect current goal status & budget, or action='complete'/'block' to update status.",
231
      "Report a state change on the active persistent task goal for this session. " +
232
      "The goal's objective, status, and budget already accompany each turn; " +
233
      "call this with action='complete' when the goal is done and verified, or 'block'/'pause'/'resume' to update its status.",
221 234
    parameters: {
222 235
      type: "object",
223 236
      properties: {
224 237
        action: {
225 238
          type: "string",
226
          enum: ["get", "complete", "block", "pause", "resume"],
239
          enum: ["complete", "block", "pause", "resume"],
227 240
          description: "The goal operation to perform.",
228 241
        },
229 242
        notes: {

@@ -234,24 +247,9 @@ export function goalTool(goalStore: GoalStore): CoderTool {

234 247
      required: ["action"],
235 248
    },
236 249
    async run(args: Record<string, unknown>): Promise<string> {
237
      const action = String(args.action || "get");
250
      const action = String(args.action ?? "");
238 251
      const current = goalStore.getGoal();
239 252
240
      if (action === "get") {
241
        if (current === undefined) {
242
          return JSON.stringify({ active: false, message: "No active goal set." });
243
        }
244
        return JSON.stringify({
245
          active: true,
246
          id: current.id,
247
          objective: current.objective,
248
          status: current.status,
249
          tokensUsed: current.tokensUsed,
250
          tokenBudget: current.tokenBudget,
251
          timeUsedSeconds: current.timeUsedSeconds,
252
        });
253
      }
254
255 253
      if (current === undefined) {
256 254
        return "Refusal: No active goal to update.";
257 255
      }
packages/openagents-cli/src/coder-session.ts modified +19 -1

@@ -28,6 +28,8 @@ import type { CoderTask, CoderTaskId, CoderTaskRegistry } from "./coder-tasks.js

28 28
import type { CoderTool } from "./coder-tools.js";
29 29
import {
30 30
  formatGoalNotice,
31
  goalBudgetExhaustedPrompt,
32
  goalContinuationPrompt,
31 33
  isGoalSlashCommand,
32 34
  parseGoalSlashCommand,
33 35
  type GoalStore,

@@ -1154,7 +1156,23 @@ export class CoderSession {

1154 1156
        this.source.useContext !== undefined
1155 1157
          ? prompt
1156 1158
          : `${this.standing}\n\n---\n\n${prompt}`;
1157
      const outgoing = attached === undefined ? sent : `${sent}\n\n${attached}`;
1159
      const withAttached = attached === undefined ? sent : `${sent}\n\n${attached}`;
1160
1161
      // The goal is standing state, so it rides the turn rather than waiting
1162
      // for the model to ask: an active objective goes out with every prompt,
1163
      // and a spent budget goes out as the instruction to wind down. Any other
1164
      // status — paused, completed, blocked — has nothing for the model to act
1165
      // on, and injects nothing.
1166
      const goal = this.goalStore?.getGoal();
1167
      const goalNote =
1168
        goal === undefined
1169
          ? undefined
1170
          : goal.status === "active"
1171
            ? goalContinuationPrompt(goal)
1172
            : goal.status === "budget_limited"
1173
              ? goalBudgetExhaustedPrompt(goal)
1174
              : undefined;
1175
      const outgoing = goalNote === undefined ? withAttached : `${withAttached}\n\n${goalNote}`;
1158 1176
1159 1177
      for await (const chunk of this.source.reply(outgoing, controller.signal)) {
1160 1178
        if (controller.signal.aborted) break;
packages/openagents-cli/test/coder-goals.test.ts modified +19 -4

@@ -78,10 +78,6 @@ describe("InMemoryGoalStore and goalTool", () => {

78 78
79 79
    // Tool interactions
80 80
    const tool = goalTool(store);
81
    const getRes = JSON.parse(await tool.run({ action: "get" }, new AbortController().signal));
82
    expect(getRes.active).toBe(true);
83
    expect(getRes.status).toBe("budget_limited");
84
85 81
    const completeRes = await tool.run({ action: "complete" }, new AbortController().signal);
86 82
    expect(completeRes).toContain("marked as completed");
87 83
    expect(store.getGoal()?.status).toBe("completed");

@@ -90,6 +86,25 @@ describe("InMemoryGoalStore and goalTool", () => {

90 86
    expect(store.clearGoal()).toBe(true);
91 87
    expect(store.getGoal()).toBeUndefined();
92 88
  });
89
90
  it("no longer offers or accepts a get action", async () => {
91
    // Reading the goal stopped being an action (issue #60): the objective
92
    // rides every outgoing turn, so the tool keeps only the state changes the
93
    // model genuinely decides.
94
    const store = new InMemoryGoalStore();
95
    store.setGoal("Write test suite");
96
    const tool = goalTool(store);
97
98
    const parameters = tool.parameters as {
99
      properties: { action: { enum: string[] } };
100
    };
101
    expect(parameters.properties.action.enum).toEqual(["complete", "block", "pause", "resume"]);
102
    expect(tool.description).not.toContain("'get'");
103
104
    const rejected = await tool.run({ action: "get" }, new AbortController().signal);
105
    expect(rejected).toContain("Unknown action");
106
    expect(store.getGoal()?.status).toBe("active");
107
  });
93 108
});
94 109
95 110
describe("formatGoalNotice and Prompts", () => {
packages/openagents-cli/test/coder-session.test.ts modified +75

@@ -681,3 +681,78 @@ describe("the /goal command in CoderSession", () => {

681 681
    expect(session.snapshot().goal).toBeUndefined();
682 682
  });
683 683
});
684
685
describe("goal injection into the outgoing turn", () => {
686
  /** A source that records the prompts it was actually handed. */
687
  const recording = (sent: string[]): ReplySource => ({
688
    model: "scripted",
689
    async *reply(prompt) {
690
      sent.push(prompt);
691
      yield { type: "text", value: "ok" } as const;
692
    },
693
  });
694
695
  const withStore = (sent: string[], store: InMemoryGoalStore): CoderSession =>
696
    new CoderSession(
697
      recording(sent),
698
      "repo",
699
      "main",
700
      undefined,
701
      undefined,
702
      undefined,
703
      undefined,
704
      undefined,
705
      store,
706
    );
707
708
  it("puts the active goal's objective on the prompt without being asked", async () => {
709
    const sent: string[] = [];
710
    const store = new InMemoryGoalStore();
711
    store.setGoal("Ship the goal injection path", 50000);
712
    const session = withStore(sent, store);
713
714
    await session.submit("hi");
715
716
    expect(sent).toHaveLength(1);
717
    expect(sent[0]).toContain("hi");
718
    expect(sent[0]).toContain("Continue working toward the active task goal.");
719
    expect(sent[0]).toContain("Ship the goal injection path");
720
    expect(sent[0]).toContain("Token budget remaining: 50,000 tokens");
721
  });
722
723
  it("sends the prompt untouched when no goal is set", async () => {
724
    const sent: string[] = [];
725
    const session = withStore(sent, new InMemoryGoalStore());
726
727
    await session.submit("hi");
728
729
    expect(sent).toEqual(["hi"]);
730
  });
731
732
  it("injects the wind-down prompt when the budget is exhausted", async () => {
733
    const sent: string[] = [];
734
    const store = new InMemoryGoalStore();
735
    store.setGoal("Ship the goal injection path", 1000);
736
    store.addUsage(1500, 5);
737
    expect(store.getGoal()?.status).toBe("budget_limited");
738
    const session = withStore(sent, store);
739
740
    await session.submit("continue");
741
742
    expect(sent).toHaveLength(1);
743
    expect(sent[0]).toContain("reached its configured token budget");
744
    expect(sent[0]).toContain("Ship the goal injection path");
745
  });
746
747
  it("injects nothing for a paused goal", async () => {
748
    const sent: string[] = [];
749
    const store = new InMemoryGoalStore();
750
    store.setGoal("Ship the goal injection path");
751
    store.updateStatus("paused");
752
    const session = withStore(sent, store);
753
754
    await session.submit("hi");
755
756
    expect(sent).toEqual(["hi"]);
757
  });
758
});
scripts/uncalled-production-symbol-baseline.json modified -8

@@ -2443,14 +2443,6 @@

2443 2443
    {
2444 2444
      "ref": "packages/sovereign-identity/src/machinery/retire.ts#ReadOnlyLegacyPlaintextGuardInterface.deleteFile",
2445 2445
      "reason": "Fail-closed half of the read-only legacy-plaintext guard: it exists to REFUSE deleting a protected legacy file while verification is active. Its sibling writeFile is unflagged only because .writeFile collides with common production dot-accesses; dropping the delete refusal would leave the guard able to block writes but not deletions."
2446
    },
2447
    {
2448
      "ref": "packages/openagents-cli/src/coder-goals.ts#goalBudgetExhaustedPrompt",
2449
      "reason": "Goal prompt helper for autonomous turn continuation when token budget is exhausted; used by goal test suite and future autonomous loop."
2450
    },
2451
    {
2452
      "ref": "packages/openagents-cli/src/coder-goals.ts#goalContinuationPrompt",
2453
      "reason": "Goal prompt helper for autonomous turn continuation toward active goal; used by goal test suite and future autonomous loop."
2454 2446
    }
2455 2447
  ]
2456 2448
}

This page updates live while a promote is in flight · changelog