Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/hands-off-await-gates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@gemstack/the-framework': patch
---

A Claude web run no longer deadlocks on an ambiguous prompt. The system prompt tells the agent to show choices and await on ambiguity, and a cloud session obeyed it into a question nobody attached could answer, spending the session for nothing. A hands-off run's system channel now declares the await gates unavailable in that session, right after the await protocol it amends, so the agent takes the most plausible reading, says which assumption it made, and carries the work through. Worded as availability rather than as a rule, so it deletes itself cleanly once choices become a per-session capability.
10 changes: 10 additions & 0 deletions packages/the-framework/prompts/protocols/hands_off.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
## Await gates are not available in this session
This session runs hands-off: it was handed to a remote service, and nothing that can answer an
await gate is attached to it. A gate you park on may never be answered, and the session is spent
for nothing.
So when these instructions say to showChoices() / showMultiSelect() / showMarkdown() and then
AWAIT, that capability is not available here. Do not emit an await block and do not stop:
- take the most plausible interpretation, the option you would have marked `recommended`
- state in one line which assumption you made
- carry the work through to the end
The non-blocking blocks (show-markdown, set-session-name, ready-for-merge) are unaffected.
2 changes: 1 addition & 1 deletion packages/the-framework/src/prompt-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ export async function runPrompt(opts: RunPromptOptions): Promise<RunPromptResult
// it can bind to, read through the same injected seam the gate resolves against so no `node:fs`
// reaches this path. Absent for a non-topic run.
const topicProjects = opts.topic && opts.bind ? (await opts.bind.listProjects()).map(p => p.path) : undefined
const system = composeRunSystem({ antiLazyPill: opts.antiLazyPill, browser: opts.browser, topic: opts.topic, ...(topicProjects ? { topicProjects } : {}), transparent: opts.transparent, user: opts.systemPrompt, tf, context: opts.context })
const system = composeRunSystem({ antiLazyPill: opts.antiLazyPill, browser: opts.browser, handsOff: opts.driver.handsOff === true, topic: opts.topic, ...(topicProjects ? { topicProjects } : {}), transparent: opts.transparent, user: opts.systemPrompt, tf, context: opts.context })
// The template's `# User prompt` half carries the prompt (today it renders to
// exactly `opts.prompt`; any framing Rom adds around the slot rides along). With
// the built-in prompt off (or transparent, #625), the raw prompt is sent as-is.
Expand Down
14 changes: 14 additions & 0 deletions packages/the-framework/src/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1000,6 +1000,20 @@ test('a hand-off run ends at the hand-off: no review passes, no backlog gate (#1
}
})

test('a hand-off run is told the await gates are unavailable, a local one is not (#1234)', async () => {
// Our own prompt says "ambiguous prompt: showChoices + AWAIT". A cloud session that obeys it
// parks forever on a question nobody attached can answer. The hands-off block amends the
// await protocol for exactly these runs, and only these.
const systemOf = async (driver: Driver): Promise<string> => {
const events: FrameworkEvent[] = []
await runFramework({ intent: FAKE_INTENT, driver, cwd: '/tmp/ws', signals: FAKE_SIGNALS, onEvent: e => events.push(e) })
const prompt = events.find(e => e.kind === 'system-prompt')
return prompt?.kind === 'system-prompt' ? prompt.text : ''
}
assert.ok((await systemOf(handsOffDriver().driver)).includes('Await gates are not available'))
assert.ok(!(await systemOf(new FakeDriver())).includes('Await gates are not available'))
})

test('a hand-off run does not stay open for messages (#1225)', async () => {
const { driver } = handsOffDriver()
// Left open on purpose: a run that still waited on it would never resolve, since the
Expand Down
1 change: 1 addition & 0 deletions packages/the-framework/src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,7 @@ export async function runFramework(opts: RunFrameworkOptions): Promise<RunFramew
const system = composeRunSystem({
antiLazyPill: opts.antiLazyPill,
browser: opts.browser,
handsOff: opts.driver.handsOff === true,
topic: opts.topic,
...(topicProjects ? { topicProjects } : {}),
transparent: opts.transparent,
Expand Down
25 changes: 24 additions & 1 deletion packages/the-framework/src/system-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ test('CONTEXT_DOCS is the #683 fragment: business knowledge plus the roadmap/que
const conversations = CONTEXT_DOCS.find(d => d.path.startsWith(`${THE_FRAMEWORK_DIR}/${CONVERSATIONS_DIR}/`))
assert.ok(conversations, `expected the ${THE_FRAMEWORK_DIR}/${CONVERSATIONS_DIR}/ pointer`)
})
import { AWAIT_PROTOCOL, BROWSER_PROTOCOL, SIGNAL_PROTOCOL } from './turn-gate.js'
import { AWAIT_PROTOCOL, BROWSER_PROTOCOL, HANDS_OFF_PROTOCOL, SIGNAL_PROTOCOL } from './turn-gate.js'

test('loadUserSystemPrompt reads and trims SYSTEM.md', async () => {
const dir = await mkdtemp(join(tmpdir(), 'system-prompt-'))
Expand Down Expand Up @@ -331,6 +331,29 @@ test('the browser section survives --vanilla but not transparent (#824)', () =>
assert.equal(composeRunSystem({ transparent: true, browser: true }), '')
})

test('composeRunSystem stays quiet about hands-off unless the run is one (#1234)', () => {
// A local run's gates work; telling it they do not would auto-answer questions a human is
// sitting right there to take.
assert.ok(!composeRunSystem().includes(HANDS_OFF_PROTOCOL))
})

test('composeRunSystem declares the await gates unavailable on a hands-off run (#1234)', () => {
// A cloud session that obeys "ambiguous prompt: showChoices + AWAIT" parks forever on a
// question nobody attached can answer, and the session is spent for nothing. The block rides
// right after the await protocol it amends, and the signal protocol stays last (#547).
const system = composeRunSystem({ handsOff: true })
assert.ok(system.includes(HANDS_OFF_PROTOCOL))
assert.ok(system.indexOf(HANDS_OFF_PROTOCOL) > system.indexOf(AWAIT_PROTOCOL))
assert.ok(system.endsWith(SIGNAL_PROTOCOL), 'the signal protocol is still last (#547)')
})

test('the hands-off block survives --vanilla but not transparent (#1234)', () => {
// Availability is a property of the session, not of the built-in prompt: --vanilla still
// teaches the gates, so it still has to say they cannot be answered here.
assert.ok(composeRunSystem({ antiLazyPill: false, handsOff: true }).includes(HANDS_OFF_PROTOCOL))
assert.equal(composeRunSystem({ transparent: true, handsOff: true }), '')
})

test('composeRunSystem keeps the emit protocols even with the built-in prompt off (#500/#501)', () => {
// The drift that #500 fixed, now pinned at the single assembly point: --vanilla drops the
// #326 block, but the agent still gets the AWAIT + SIGNAL emit contract.
Expand Down
15 changes: 13 additions & 2 deletions packages/the-framework/src/system-prompt.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { renderTemplate } from './prompt-template.js'
import { SYSTEM_PROMPT, TICKETING_FORMAT, TODO_FORMAT } from './prompts.generated.js'
import { AWAIT_PROTOCOL, BROWSER_PROTOCOL, SIGNAL_PROTOCOL } from './turn-gate.js'
import { AWAIT_PROTOCOL, BROWSER_PROTOCOL, HANDS_OFF_PROTOCOL, SIGNAL_PROTOCOL } from './turn-gate.js'

/**
* Topic-run bind protocol (#1121): told only to a project-less "topic" run (#1120) so the agent
Expand Down Expand Up @@ -316,6 +316,13 @@ export interface SystemPromptOptions {
* {@link topic}. The node side reads {@link ./registry.listProjects} and passes the paths in.
*/
topicProjects?: readonly string[] | undefined
/**
* This run hands off to a remote session nothing local can steer (#1231), so the await gates
* are not available in it (#1234). Appends {@link HANDS_OFF_PROTOCOL} right after the await
* protocol it amends, so an ambiguous prompt takes its most plausible reading and says so,
* instead of parking a cloud session forever on a question nobody attached can answer.
*/
handsOff?: boolean | undefined
}

/**
Expand Down Expand Up @@ -382,5 +389,9 @@ export function composeRunSystem(opts: RunSystemOptions = {}): string {
// after it and keeps the signal protocol last (#547). Carries the registered-project list as
// context (#1129). Topic-only, so a normal channel is unchanged.
const topicBind = opts.topic ? [topicBindBlock(opts.topicProjects)] : []
return [...(promptBlock ? [promptBlock] : []), ...browser, AWAIT_PROTOCOL, ...topicBind, SIGNAL_PROTOCOL].join('\n\n')
// Right after the await protocol it amends (#1234): the gates are taught, then declared
// unavailable, which keeps the emit contract intact for the parser while telling the agent
// not to reach for it. The signal protocol stays last (#547).
const handsOff = opts.handsOff ? [HANDS_OFF_PROTOCOL] : []
return [...(promptBlock ? [promptBlock] : []), ...browser, AWAIT_PROTOCOL, ...handsOff, ...topicBind, SIGNAL_PROTOCOL].join('\n\n')
}
11 changes: 10 additions & 1 deletion packages/the-framework/src/turn-gate.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { FrameworkEvent } from './events.js'
import { PROTOCOLS_BROWSER, PROTOCOLS_AWAIT, PROTOCOLS_SIGNAL } from './prompts.generated.js'
import { PROTOCOLS_BROWSER, PROTOCOLS_AWAIT, PROTOCOLS_HANDS_OFF, PROTOCOLS_SIGNAL } from './prompts.generated.js'
import type { ChoicesOption } from './await-gate.js'
import type { MultiSelectOption } from './await-gate.js'

Expand All @@ -15,6 +15,15 @@ import type { MultiSelectOption } from './await-gate.js'
*/
export const AWAIT_PROTOCOL = PROTOCOLS_AWAIT

/**
* Told to a hands-off run only (#1234): the await gates {@link AWAIT_PROTOCOL} just taught are
* not available in this session, so an ambiguous prompt takes its most plausible reading instead
* of parking forever on a question nobody attached can answer. Worded as availability rather
* than as a rule, so it deletes itself cleanly once choices become a per-session capability.
* The text lives in `prompts/protocols/hands_off.md`.
*/
export const HANDS_OFF_PROTOCOL = PROTOCOLS_HANDS_OFF

/**
* Told to the agent only when the run has a browser (#824): that it has one, and that anything
* it needs to see or act on goes through the chrome-devtools tools rather than `WebFetch`.
Expand Down
Loading