Skip to content

Commit bf04d04

Browse files
authored
feat(goals): keep active session goals in per-turn context + continuance QA scenarios (#100468)
* feat(goals): keep active session goals in per-turn context (#100409) Active goals now inject one bounded user-role context line on every non-heartbeat turn, refreshed at queued/interrupt admission via provenance-tracked generated lines so operator stops take effect immediately. Adds qa/scenarios/goals continuance scenarios and goals.* coverage IDs. * docs: regenerate docs map for goal context section * fix(qa): use requiredChannelDriver instead of execution.channel pin in goal scenarios
1 parent 97227bd commit bf04d04

17 files changed

Lines changed: 922 additions & 13 deletions

docs/docs_map.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9205,6 +9205,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
92059205
- H2: Statuses
92069206
- H2: Token budgets
92079207
- H2: Model tools
9208+
- H2: Goal context on every turn
92089209
- H2: TUI
92099210
- H2: Channel behavior
92109211
- H2: Troubleshooting

docs/tools/goal.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,18 @@ actually achieved. It should mark a goal `blocked` only after the same
143143
blocking condition recurs for at least three consecutive goal turns, not for
144144
ordinary difficulty or missing polish.
145145

146+
## Goal context on every turn
147+
148+
Every user/chat turn with an active goal includes this user-role context line:
149+
150+
```text
151+
Active goal: <objective> — advance it or update its status (get_goal/update_goal).
152+
```
153+
154+
OpenClaw keeps the line compact by truncating long objectives. Paused,
155+
blocked, budget-limited, usage-limited, and complete goals are not injected,
156+
so an operator stop remains in effect until the goal is resumed.
157+
146158
## TUI
147159

148160
The TUI footer keeps the active session's goal visible next to the agent,
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
title: Active goal context on the next turn
2+
3+
scenario:
4+
id: goal-context-next-turn
5+
surface: goals
6+
category: session-memory-and-context-engine.context-engine
7+
coverage:
8+
primary:
9+
- goals.context-injection
10+
secondary:
11+
- goals.continuance
12+
objective: Verify an active session goal is injected into the next unrelated turn sent to the model.
13+
successCriteria:
14+
- An authorized channel turn starts a session goal through the public `/goal start` command.
15+
- A later unrelated inbound message reaches the mock model in the same session.
16+
- The later request contains the exact active-goal context line and objective.
17+
- The assertion reads the captured provider request rather than inferring context from the visible reply.
18+
docsRefs:
19+
- docs/tools/goal.md
20+
- docs/channels/qa-channel.md
21+
codeRefs:
22+
- src/auto-reply/reply/inbound-meta.ts
23+
- src/config/sessions/goals.ts
24+
- extensions/qa-lab/src/providers/mock-openai/server.ts
25+
execution:
26+
kind: flow
27+
summary: Start a goal, send an unrelated next turn, and inspect the exact mock-provider request payload.
28+
config:
29+
requiredChannelDriver: qa-channel
30+
requiredProviderMode: mock-openai
31+
conversationId: goal-context-next-turn
32+
senderId: qa-goal-operator
33+
goalObjective: Compile the QA release evidence bundle
34+
expectedGoalContext: "Active goal: Compile the QA release evidence bundle — advance it or update its status (get_goal/update_goal)."
35+
secondMessage: GOAL-CONTEXT-NEXT-TURN unrelated weather question
36+
37+
flow:
38+
steps:
39+
- name: injects active goal context into the next unrelated turn
40+
actions:
41+
- assert:
42+
expr: "env.providerMode === config.requiredProviderMode"
43+
message: this request-payload assertion requires mock-openai
44+
- call: waitForGatewayHealthy
45+
args:
46+
- ref: env
47+
- 60000
48+
- call: waitForQaChannelReady
49+
args:
50+
- ref: env
51+
- 60000
52+
- resetTransport: true
53+
- set: conversationId
54+
value:
55+
expr: "`${config.conversationId}-${randomUUID().slice(0, 8)}`"
56+
- set: firstOutboundIndex
57+
value:
58+
expr: "state.getSnapshot().messages.filter((message) => message.direction === 'outbound').length"
59+
- sendInbound:
60+
conversation:
61+
id:
62+
ref: conversationId
63+
kind: direct
64+
senderId:
65+
ref: config.senderId
66+
senderName: QA Goal Operator
67+
text:
68+
expr: "`/goal start ${config.goalObjective}`"
69+
- waitForOutbound:
70+
conversation:
71+
id:
72+
ref: conversationId
73+
kind: direct
74+
sinceIndex:
75+
ref: firstOutboundIndex
76+
timeoutMs:
77+
expr: liveTurnTimeoutMs(env, 60000)
78+
saveAs: goalStartReply
79+
- set: requestCountBeforeSecondTurn
80+
value:
81+
expr: "(await fetchJson(`${env.mock.baseUrl}/debug/requests`)).length"
82+
- set: secondOutboundIndex
83+
value:
84+
expr: "state.getSnapshot().messages.filter((message) => message.direction === 'outbound').length"
85+
- sendInbound:
86+
conversation:
87+
id:
88+
ref: conversationId
89+
kind: direct
90+
senderId:
91+
ref: config.senderId
92+
senderName: QA Goal Operator
93+
text:
94+
ref: config.secondMessage
95+
- call: waitForCondition
96+
saveAs: secondTurnRequest
97+
args:
98+
- lambda:
99+
async: true
100+
expr: "(await fetchJson(`${env.mock.baseUrl}/debug/requests`)).slice(requestCountBeforeSecondTurn).find((request) => String(request.prompt ?? '').includes(config.secondMessage))"
101+
- expr: liveTurnTimeoutMs(env, 60000)
102+
- 100
103+
- waitForOutbound:
104+
conversation:
105+
id:
106+
ref: conversationId
107+
kind: direct
108+
sinceIndex:
109+
ref: secondOutboundIndex
110+
timeoutMs:
111+
expr: liveTurnTimeoutMs(env, 60000)
112+
saveAs: secondReply
113+
- assert:
114+
expr: "String(secondTurnRequest.prompt ?? '').includes(config.expectedGoalContext)"
115+
message:
116+
expr: "`second-turn provider prompt missed active goal context: ${String(secondTurnRequest.prompt ?? '')}`"
117+
detailsExpr: "`goal start reply=${goalStartReply.text}; second reply=${secondReply.text}`"
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
title: Active goal context survives compaction
2+
3+
scenario:
4+
id: goal-context-survives-compaction
5+
surface: goals
6+
category: session-memory-and-context-engine.context-engine
7+
coverage:
8+
primary:
9+
- goals.context-injection
10+
secondary:
11+
- goals.continuance
12+
- runtime.compaction
13+
objective: Verify compaction does not remove active session-goal context from the next model turn.
14+
successCriteria:
15+
- An authorized channel turn starts a session goal through `/goal start`.
16+
- The same session completes a manual `/compact` operation.
17+
- The first post-compaction inbound turn reaches the mock model with the exact active-goal context line.
18+
- The assertion uses the captured post-compaction provider request.
19+
gatewayConfigPatch:
20+
agents:
21+
defaults:
22+
compaction:
23+
reserveTokens: 64
24+
reserveTokensFloor: 0
25+
keepRecentTokens: 64
26+
docsRefs:
27+
- docs/tools/goal.md
28+
- docs/concepts/compaction.md
29+
codeRefs:
30+
- src/auto-reply/reply/inbound-meta.ts
31+
- src/auto-reply/reply/commands-compact.ts
32+
- extensions/qa-lab/src/providers/mock-openai/server.ts
33+
execution:
34+
kind: flow
35+
summary: Start a goal, compact the session, then inspect the next mock-provider request for active-goal context.
36+
config:
37+
requiredChannelDriver: qa-channel
38+
requiredProviderMode: mock-openai
39+
conversationId: goal-context-compaction
40+
senderId: qa-goal-operator
41+
goalObjective: Preserve the deployment audit objective
42+
expectedGoalContext: "Active goal: Preserve the deployment audit objective — advance it or update its status (get_goal/update_goal)."
43+
contextSeedMarker: GOAL-COMPACTION-CONTEXT-SEED
44+
postCompactionMessage: GOAL-CONTEXT-POST-COMPACTION unrelated lunch question
45+
46+
flow:
47+
steps:
48+
- name: injects active goal context after manual compaction
49+
actions:
50+
- assert:
51+
expr: "env.providerMode === config.requiredProviderMode"
52+
message: this request-payload assertion requires mock-openai
53+
- call: waitForGatewayHealthy
54+
args:
55+
- ref: env
56+
- 60000
57+
- call: waitForQaChannelReady
58+
args:
59+
- ref: env
60+
- 60000
61+
- resetTransport: true
62+
- set: conversationId
63+
value:
64+
expr: "`${config.conversationId}-${randomUUID().slice(0, 8)}`"
65+
- set: outboundIndex
66+
value:
67+
expr: "state.getSnapshot().messages.filter((message) => message.direction === 'outbound').length"
68+
- sendInbound:
69+
conversation:
70+
id:
71+
ref: conversationId
72+
kind: direct
73+
senderId:
74+
ref: config.senderId
75+
senderName: QA Goal Operator
76+
text:
77+
expr: "`/goal start ${config.goalObjective}`"
78+
- waitForOutbound:
79+
conversation:
80+
id:
81+
ref: conversationId
82+
kind: direct
83+
sinceIndex:
84+
ref: outboundIndex
85+
timeoutMs:
86+
expr: liveTurnTimeoutMs(env, 60000)
87+
- set: seedOutboundIndex
88+
value:
89+
expr: "state.getSnapshot().messages.filter((message) => message.direction === 'outbound').length"
90+
- sendInbound:
91+
conversation:
92+
id:
93+
ref: conversationId
94+
kind: direct
95+
senderId:
96+
ref: config.senderId
97+
senderName: QA Goal Operator
98+
text:
99+
expr: "`${config.contextSeedMarker} ${'retain this audit context '.repeat(80)}`"
100+
- waitForOutbound:
101+
conversation:
102+
id:
103+
ref: conversationId
104+
kind: direct
105+
sinceIndex:
106+
ref: seedOutboundIndex
107+
timeoutMs:
108+
expr: liveTurnTimeoutMs(env, 60000)
109+
- set: compactOutboundIndex
110+
value:
111+
expr: "state.getSnapshot().messages.filter((message) => message.direction === 'outbound').length"
112+
- sendInbound:
113+
conversation:
114+
id:
115+
ref: conversationId
116+
kind: direct
117+
senderId:
118+
ref: config.senderId
119+
senderName: QA Goal Operator
120+
text: /compact
121+
- waitForOutbound:
122+
conversation:
123+
id:
124+
ref: conversationId
125+
kind: direct
126+
sinceIndex:
127+
ref: compactOutboundIndex
128+
textIncludes: Compact
129+
timeoutMs:
130+
expr: liveTurnTimeoutMs(env, 60000)
131+
saveAs: compactReply
132+
- assert:
133+
expr: "!normalizeLowercaseStringOrEmpty(compactReply.text).includes('skipped')"
134+
message:
135+
expr: "`manual compaction did not compact the seeded session: ${compactReply.text}`"
136+
- set: requestCountBeforePostCompactionTurn
137+
value:
138+
expr: "(await fetchJson(`${env.mock.baseUrl}/debug/requests`)).length"
139+
- set: postCompactOutboundIndex
140+
value:
141+
expr: "state.getSnapshot().messages.filter((message) => message.direction === 'outbound').length"
142+
- sendInbound:
143+
conversation:
144+
id:
145+
ref: conversationId
146+
kind: direct
147+
senderId:
148+
ref: config.senderId
149+
senderName: QA Goal Operator
150+
text:
151+
ref: config.postCompactionMessage
152+
- call: waitForCondition
153+
saveAs: postCompactionRequest
154+
args:
155+
- lambda:
156+
async: true
157+
expr: "(await fetchJson(`${env.mock.baseUrl}/debug/requests`)).slice(requestCountBeforePostCompactionTurn).find((request) => String(request.prompt ?? '').includes(config.postCompactionMessage))"
158+
- expr: liveTurnTimeoutMs(env, 60000)
159+
- 100
160+
- waitForOutbound:
161+
conversation:
162+
id:
163+
ref: conversationId
164+
kind: direct
165+
sinceIndex:
166+
ref: postCompactOutboundIndex
167+
timeoutMs:
168+
expr: liveTurnTimeoutMs(env, 60000)
169+
saveAs: postCompactionReply
170+
- assert:
171+
expr: "String(postCompactionRequest.prompt ?? '').includes(config.expectedGoalContext)"
172+
message:
173+
expr: "`post-compaction provider prompt missed active goal context: ${String(postCompactionRequest.prompt ?? '')}`"
174+
detailsExpr: "`compact=${compactReply.text}; post-compaction=${postCompactionReply.text}`"

0 commit comments

Comments
 (0)