fix: give direct chats the current request, delivery contract, and full task

Commit 9198bf6 made direct chats send only the latest message text and
rely on the native session. After 1c9aabb a rebuilt session received the
continuation text instead, and that text deliberately excludes the
latest user message, so the Agent never saw the request it was answering.
Direct prompts also lost the deliverable contract (.meldwork-output/),
the read-only media warning, staged attachment paths, and regeneration
and recovery instructions, which is why generated files were not
captured and three media tests failed on bb55fad.

- New directPromptFor() leads with the user's own words. A new or rebuilt
  native session gets the earlier conversation plus "Current request:";
  a resumed session gets the request alone. The full delivery and
  language contract is sent once per native session, a one-line reminder
  afterwards. Staged inputs, recovery and regeneration instructions are
  appended again.
- The shared deliveryContract() is used by group and direct prompts.
- Isolated (task-graph) invocations again neither resume nor persist the
  conversation's native session (3ce8ee4 made them resume it, breaking
  the "approved prompt only" contract).
- The current task is no longer cut to 6,000 characters, and longer
  messages no longer fail every turn with
  LOCAL_RUN_REQUIRED_CONTEXT_OVERFLOW: submitted messages are already
  capped at 20,000 characters, so the full request is delivered.

Checks: local-workspace-harness (73) and local-workspace-context-packs
(15) pass on this tree, plus the direct, media and custom-label tests in
local-workspace and native-streaming (18), five of which failed on
bb55fad.
This commit is contained in:
Ryder Sun committed 2026-10-15 17:39:18 +08:00
1 parent 2da40db63d
commit 624a099bdd
5 files changed
+120 -29

No files matched your search

@@ -17,6 +17,7 @@ const {
normalizeContextPackId,
} = require('../collaboration/context-pack-records.cjs')
const {
defaultAgentLabel,
HERMES_WORKSPACE_ACP_ENABLED,
MAX_MESSAGE_ATTACHMENTS,
abortableOperation,
@@ -29,6 +30,7 @@ const {
settleWithin,
} = require('./local-workspace-inputs.cjs')
const { assertLocalSkillExecution } = require('../skills/local-skill-contract.cjs')
const { directPromptFor } = require('./local-workspace-context.cjs')
const { outboundWirePayloadBytes } = require('../collaboration/outbound-payload.cjs')
const { processRunScheduler } = require('../runs/run-scheduler.cjs')
const {
@@ -515,6 +517,9 @@ class LocalWorkspaceAgentInvocation {
this.scheduleRunCheckpoint = options.scheduleRunCheckpoint
this.emitChanged = options.emitChanged
this.promptFor = options.promptFor
this.agentLabel = typeof options.agentLabel === 'function'
? options.agentLabel
: kind => defaultAgentLabel(kind)
this.persistSessionState = options.persistSessionState
this.createAttemptContextPack = options.createAttemptContextPack
this.recordContextDelivery = options.recordContextDelivery
@@ -704,7 +709,9 @@ class LocalWorkspaceAgentInvocation {
}
: null
const idempotencyMode = agent.idempotencyMode === 'durable' ? 'durable' : 'none'
const resolvedSession = reviewOnly
// Isolated invocations must see only their approved prompt, so they never
// resume or persist the conversation's native session.
const resolvedSession = reviewOnly || isolated
? {
key: this.sessionKey(group.id, kind),
sessionRef: '',
@@ -1278,10 +1285,24 @@ class LocalWorkspaceAgentInvocation {
const buildPrompt = (afterKind, contextPackage) => isolated || frozen
? v4Prompt
: group.conversationType === 'direct'
? (sessionRotated
? (contextPackage.continuationText || contextPackage.stableText
|| contextPackage.currentTaskText)
: contextPackage.currentTaskText)
? [
// A resumed native session already holds the conversation. A new
// or rebuilt session needs the earlier turns and, always, the
// current request plus the per-turn delivery contract.
directPromptFor({
group,
label: this.agentLabel(kind),
packed: contextPackage,
skillHints: context.skillHints || [],
knowledgeBaseHints: context.knowledgeBaseHints || [],
includeHistory: sessionRotated || !sessionRef,
}),
stagedAgentInputPrompt(stagedInputs),
runtimeInstruction ? `Harness recovery task:\n${runtimeInstruction}` : '',
regenerationInstruction
? `Fresh response instruction:\n${regenerationInstruction}`
: '',
].filter(Boolean).join('\n\n')
: [
context.v4 === true && v4Prompt
? v4Prompt
@@ -1441,7 +1462,7 @@ class LocalWorkspaceAgentInvocation {
onSessionRef: (nextSessionRef, metadata = {}) => {
if (agentCallbacksClosed || agentController.signal.aborted) return
noteWatchdogProgress()
if (reviewOnly) return
if (reviewOnly || isolated) return
const transport = ['legacy', 'acp'].includes(metadata?.transport)
? metadata.transport
: ''
@@ -1454,7 +1475,7 @@ class LocalWorkspaceAgentInvocation {
}))
},
onSessionInvalidated: () => {
if (kind !== 'hermes' || reviewOnly || resumedPermission) return null
if (kind !== 'hermes' || reviewOnly || isolated || resumedPermission) return null
return { prompt: rebuildFreshSession() }
},
signal: agentController.signal,
@@ -1904,7 +1925,7 @@ class LocalWorkspaceAgentInvocation {
pendingMessage.metadata,
)
: null
if (!reviewOnly) {
if (!reviewOnly && !isolated) {
this.persistSessionState(key, result.sessionRef || sessionRef, completedSessionMeta(
sessionMeta, sessionProvenance, taskId, {
promptChars: prompt.length,
@@ -21,10 +21,15 @@ const {
normalizeSkillHint,
skillHintsPrompt,
} = require('./local-workspace-inputs.cjs')
const { MAX_MESSAGE_TEXT_CHARS } = require('./local-workspace-contracts.cjs')
const CONTINUATION_STABLE_CONTEXT_TEXT_LIMIT = 1400
const CONTINUATION_RECENT_CONTEXT_TEXT_LIMIT = 4600
const CURRENT_TASK_TEXT_LIMIT = 6000
// User messages are already capped at MAX_MESSAGE_TEXT_CHARS when they are
// submitted. The current task must reach the Agent in full: truncating it, or
// failing the turn when it is longer than a fixed budget, made long pasted
// documents unusable. The extra room covers the attached-file note.
const CURRENT_TASK_TEXT_LIMIT = MAX_MESSAGE_TEXT_CHARS + 2000
const V4_SNAPSHOT_HISTORY_LIMIT = 16
const NATURAL_TRANSCRIPT_TEXT_LIMIT = 48000
const NATURAL_TRANSCRIPT_ENTRY_LIMIT = 20000
@@ -808,6 +813,72 @@ function packedPromptContext({
}
}
// The write-access and deliverable contract applies to every turn, including
// direct chats that rely on the native Agent session for history. Without it
// Agents do not know where to put files, so generated outputs are never
// captured and read-only chats claim media that does not exist.
function deliveryContract(group) {
return group?.allowWrite
? [
'Workspace access: You may create and edit files in the conversation working directory. When the user requests a deliverable, execute the work instead of returning only a plan.',
'Deliverable capture contract: Save or copy each final document, code file, diff, structured-data file, media file, or bundle into .meldwork-output/ in the conversation working directory.',
'Only files created or changed during this run are captured as durable outputs. Do not claim a deliverable exists until the real file has been written there.',
'When the user asks for an image, audio, or video, do not claim it was generated unless a real file exists.',
'Supported chat attachments include common images, media, documents, source code, configuration text, and archives. Other recognized outputs remain durable Artifacts.',
].join('\n')
: [
'This conversation is read-only. Do not claim that a media file was generated or delivered because no writable output can be attached.',
'If the user requests generated media, explain that workspace write access must be enabled before a real file can be delivered.',
].join('\n')
}
// Direct chats keep one native Agent session for the whole conversation, so
// a resumed turn only needs the user's request plus the per-turn contract.
// When the session is new or was rebuilt, the earlier conversation is packed
// before the request so the Agent never loses either of them.
function directPromptFor({
group,
label = '',
packed,
skillHints = [],
knowledgeBaseHints = [],
includeHistory = false,
}) {
const currentTaskText = packed?.currentTaskText || '(none)'
const latestId = String(packed?.latestUserMessageId || '')
const hasPriorContext = includeHistory
&& (packed?.continuationSourceMessageIds || []).some(id => id && id !== latestId)
// The full contract is delivered once per native session; resumed turns
// carry a one-line reminder so long chats do not re-send the same block.
const notes = includeHistory
? [
label ? `You are working in this Meldwork chat as ${label}.` : '',
deliveryContract(group),
responseLanguagePrompt(packed?.latestUserLanguage),
]
: [
group?.allowWrite
? 'Save deliverables into .meldwork-output/ in the working directory; do not claim a file exists until it was written.'
: 'This chat is read-only; do not claim that a file or media was generated or delivered.',
'Answer in the language of the current request unless it asks for another language.',
]
notes.push(skillHintsPrompt(skillHints), knowledgeBaseHintsPrompt(knowledgeBaseHints))
return [
...(hasPriorContext
? [
'Earlier conversation in this chat (reference only; verify it before relying on it):',
packed.continuationText,
'',
'Current request:',
]
: []),
currentTaskText,
'',
'Meldwork conversation notes:',
...notes.filter(Boolean),
].join('\n')
}
function promptFor({
group,
kind,
@@ -837,18 +908,7 @@ function promptFor({
packed?.currentTaskText || '(none)',
'Treat older group messages and conclusions as reference only. Do not continue an older task unless the current user task explicitly asks you to do so.',
].join('\n')
const mediaDelivery = group.allowWrite
? [
'Workspace access: You may create and edit files in the conversation working directory. When the user requests a deliverable, execute the work instead of returning only a plan.',
'Deliverable capture contract: Save or copy each final document, code file, diff, structured-data file, media file, or bundle into .meldwork-output/ in the conversation working directory.',
'Only files created or changed during this run are captured as durable outputs. Do not claim a deliverable exists until the real file has been written there.',
'When the user asks for an image, audio, or video, do not claim it was generated unless a real file exists.',
'Supported chat attachments include common images, media, documents, source code, configuration text, and archives. Other recognized outputs remain durable Artifacts.',
].join('\n')
: [
'This conversation is read-only. Do not claim that a media file was generated or delivered because no writable output can be attached.',
'If the user requests generated media, explain that workspace write access must be enabled before a real file can be delivered.',
].join('\n')
const mediaDelivery = deliveryContract(group)
if (promptMode === 'continuation') {
const continuationMediaDelivery = group.allowWrite
? 'Workspace write access remains enabled for this task; execute requested deliverables and capture them as required.'
@@ -898,6 +958,8 @@ function promptFor({
}
module.exports = {
deliveryContract,
directPromptFor,
packNaturalDiscussionTranscript,
clearSessionState,
openClawSessionRef,
@@ -267,6 +267,7 @@ class LocalWorkspace extends EventEmitter {
})
this.agentInvocation = new LocalWorkspaceAgentInvocation({
state: () => this.state,
agentLabel: kind => this.agentLabel(kind),
detectedAgents: () => this.detectedAgents,
activeRuns: this.activeRuns,
runAgentTimeoutMs: this.runAgentTimeoutMs,
@@ -463,24 +463,25 @@ test('long in-budget requests verify the exact prompt that is actually delivered
assert.equal(workspace.contextPacks.compareDelivery(delivery.deliveryRecordId).status, 'match')
})
test('required current-task overflow fails closed before Agent execution', async (t) => {
test('a long current task reaches the Agent in full instead of failing the turn', async (t) => {
const { directory, calls, options } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const workspace = new LocalWorkspace(options)
await workspace.refreshAgents()
const group = workspace.createGroup({
name: 'Required context overflow', agentKinds: ['codex'], workdir: directory,
name: 'Long current task', agentKinds: ['codex'], workdir: directory,
conversationType: 'direct', directAgentKind: 'codex',
})
const request = `Generate this image exactly: ${'dense visual direction '.repeat(500)}`
// Longer than the former 6,000-character budget that failed every turn.
const request = `Generate this image exactly: ${'dense visual direction '.repeat(800)}end-of-request`
await workspace.sendMessage({ groupId: group.id, text: request })
assert.equal(calls.length, 0)
assert.equal(calls.length, 1)
assert.equal(calls[0].prompt.includes(request.trim()), true)
assert.equal(workspace.snapshot().messages.some(message => (
message.system?.key === 'system.agentCallFailed'
&& message.system.params.reason === 'LOCAL_RUN_REQUIRED_CONTEXT_OVERFLOW'
)), true)
message.system?.params?.reason === 'LOCAL_RUN_REQUIRED_CONTEXT_OVERFLOW'
)), false)
})
test('Codex-shaped stdin delivery verifies the exact budgeted prompt', async (t) => {
@@ -1377,7 +1377,13 @@ test('direct conversations force manual mode and reuse their group Agent session
assert.equal(direct.directAgentKind, 'codex')
assert.deepEqual(calls.map(call => call.agent.kind), ['codex', 'codex'])
assert.deepEqual(calls.map(call => call.runOptions.sessionRef), ['', 'codex-session'])
assert.deepEqual(calls.map(call => call.prompt), ['第一条', '第二条'])
// Direct chats lead with the user's own words and carry no group framing.
// The delivery contract is sent in full when the native session starts and
// as a one-line reminder on resumed turns.
assert.deepEqual(calls.map(call => call.prompt.split('\n')[0]), ['第一条', '第二条'])
assert.equal(calls.some(call => /You are participating|group Session|Group topic/.test(call.prompt)), false)
assert.match(calls[0].prompt, /\.meldwork-output\//)
assert.ok(calls[1].prompt.length < calls[0].prompt.length)
assert.equal(calls.some(call => call.prompt.includes('MELDWORK_CONSENSUS')), false)
assert.equal(workspace.snapshot().messages.some(message => message.threadRootId), false)
const restored = new LocalWorkspace(options)