feat: let agents read supervisor story graphs

Expose saved decisions, timelines and source evidence through read-only Story Graph tools across Model, Harness and local/remote OpenCode and Continue. Supervisor enablement and runtime assignments govern discovery and calls; reads do not invoke models or modify stored evidence. Remote use requires matching Desktop and Agent changes.

Make Continue discussion show the complete editable message with a separated destination and shared action layout. Sending uses the visible draft without reattaching removed context.

Validation: existing focused MCP, runtime, controlled Linux Host, discussion component and Electron layout checks plus typechecks; staged diff check passed. Cross-scope fact reuse, historical reconstruction and full package acceptance remain deferred.

Release note: Agents can consult saved supervisor decisions and their sources when continuing work. Continue discussion now lets you review and edit the complete message before sending.
This commit is contained in:
mesalogo
2026-09-28 12:38:00 +08:00
parent 83a3ea261e
commit 66f77b83c2
61 changed files with 1527 additions and 150 deletions
+13 -5
View File
@@ -46,14 +46,19 @@ const server = (name: string) => ({
// Keep the actual helper and adapter config builder together: mocking the
// adapter constructor would miss the model-profile configuration-loss bug.
it('delivers current ACP session MCP capabilities through the bridged model config, but not Ask', async () => {
it('delivers current ACP session MCP capabilities in Execute and Ask with read-only Ask authorization', async () => {
const configs: Array<Record<string, unknown>> = []
vi.stubGlobal('fetch', vi.fn(async () => Response.json({
operationId: 'operation', workMode: transport.mode
})))
vi.spyOn(ContinueHostAdapter.prototype, 'run').mockImplementation(async function (
this: ContinueHostAdapter, _prompt, _signal, _authorize, options
this: ContinueHostAdapter, _prompt, _signal, authorize, options
) {
if (options?.workMode === 'ask') {
expect(await authorize!({ toolName: 'story_graph_search' } as never)).toBe('once')
expect(await authorize!({ toolName: 'generate_image' } as never)).toBe('deny')
expect(await authorize!({ toolName: 'write_file' } as never)).toBe('deny')
}
const path = await (this as unknown as {
createRunConfig(options: ContinueHostRunOptions): Promise<string>
}).createRunConfig(options!)
@@ -83,7 +88,8 @@ it('delivers current ACP session MCP capabilities through the bridged model conf
requestOptions: { headers: { Authorization: 'Bearer session-test-token' } } }],
[{ name: 'replacement', type: 'streamable-http', url: server('replacement').url,
requestOptions: { headers: { Authorization: 'Bearer session-test-token' } } }],
[], []
[{ name: 'replacement', type: 'streamable-http', url: server('replacement').url,
requestOptions: { headers: { Authorization: 'Bearer session-test-token' } } }], []
])
expect(configs[0].models).toEqual([expect.objectContaining({ model: 'test-model' })])
} finally {
@@ -92,7 +98,7 @@ it('delivers current ACP session MCP capabilities through the bridged model conf
}
})
it('keeps local profile MCP scoping and filters explicit session servers in Ask at the adapter boundary', async () => {
it('keeps local profile MCP scoping and includes Main-bound session servers in Ask', async () => {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-cn-scope-'))
try {
const configPath = join(root, 'native.json')
@@ -112,7 +118,9 @@ it('keeps local profile MCP scoping and filters explicit session servers in Ask
const path = await create({ workMode: 'ask', sessionMcpServers: [{
name: 'remote', type: 'streamable-http', url: 'http://127.0.0.1:12346', requestOptions: { headers: {} }
}] })
expect(JSON.parse(await readFile(path, 'utf8')).mcpServers ?? []).toEqual([])
expect(JSON.parse(await readFile(path, 'utf8')).mcpServers).toEqual([{
name: 'remote', type: 'streamable-http', url: 'http://127.0.0.1:12346', requestOptions: { headers: {} }
}])
} finally {
await rm(root, { recursive: true, force: true })
}
+4 -3
View File
@@ -11,6 +11,7 @@ import { promptWithUntrustedConversationHistory } from '../main/agent/runtime-co
import type { AgentExecutionRequest, AgentImage } from '../main/agent/runtime'
import { createUnixModelBridgeExchange } from './model-bridge-broker'
import { ModelBridgeLoopbackProxy, MODEL_BRIDGE_SDK_AUTH_SENTINEL, openCodeModelBridgeModelId, type ModelBridgeProtocol } from './model-bridge-helper'
import { isStoryGraphTool } from '../shared/story-graph-tools'
export async function runContinueAcpHelper(options: {
socketPath: string
@@ -88,11 +89,11 @@ export async function runContinueAcpHelper(options: {
return await response.json() as { operationId: string; workMode: 'ask' | 'execute' }
}) : undefined
const workMode = route?.workMode ?? options.workMode
const sessionMcpServers = workMode === 'execute' ? session.mcpServers.map(server => {
const sessionMcpServers = session.mcpServers.map(server => {
if (!('type' in server) || server.type !== 'http') throw new Error('Continue remote MCP requires HTTP')
return { name: server.name, type: 'streamable-http' as const, url: server.url,
requestOptions: { headers: Object.fromEntries(server.headers.map(header => [header.name, header.value])) } }
}) : []
})
const adapter = new ContinueHostAdapter({
binaryPath: options.entrypoint, configPath: '', workspace: session.cwd, cacheRoot: root, mode: 'chat',
modelProfile: { id: 'agent-bridge', name: 'GoodBuddy', modelName: options.model,
@@ -111,7 +112,7 @@ export async function runContinueAcpHelper(options: {
session.active = { abort, adapter, finished: new Promise(resolve => { finish = resolve }) }
try {
const result = await adapter.run(promptWithUntrustedConversationHistory({ prompt: text, history: session.history }, true), abort.signal,
async () => workMode === 'execute' ? 'once' : 'deny', {
async approval => workMode === 'execute' || isStoryGraphTool(approval.toolName ?? '') ? 'once' : 'deny', {
workMode, images, sessionMcpServers,
onEvent: async event => {
if (event.type === 'text') await connection.sessionUpdate({ sessionId, update: {
+21 -9
View File
@@ -5,6 +5,7 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
import { CallToolRequestSchema, ListToolsRequestSchema, type CallToolResult } from '@modelcontextprotocol/sdk/types.js'
import { z } from 'zod'
import { imageToolInputSchema, imageToolName } from '../shared/image-generation-contracts'
import { storyGraphTools, type StoryGraphToolName } from '../shared/story-graph-tools'
import {
decodeRemoteImageToolMessage, encodeRemoteImageToolMessage,
remoteImageToolMaximumBytes, remoteImageToolReplySchema, type remoteImageToolSchema
@@ -44,12 +45,23 @@ export class AgentImageToolMcp {
}
const body: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
const mcp = new McpServer({ name: 'goodbuddy-image', version: '1' }, { capabilities: { tools: {} } })
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [{
name: imageToolName, description: this.descriptor.description,
inputSchema: schema as { type: 'object' }
}] }))
mcp.setRequestHandler(ListToolsRequestSchema, async () => {
const available = this.descriptor.storyGraph ? await this.call({}, 'story_graph_list') : undefined
const graphEnabled = available?.content.some(part => part.type === 'text' && JSON.parse(part.text).available === true)
return { tools: [
...(this.descriptor.description ? [{ name: imageToolName, description: this.descriptor.description, inputSchema: schema as { type: 'object' } }] : []),
...(graphEnabled ? storyGraphTools.map(tool => ({ name: tool.name, description: tool.description,
inputSchema: z.toJSONSchema(tool.inputSchema, { target: 'draft-7', io: 'input' }) as { type: 'object' },
annotations: { readOnlyHint: true, destructiveHint: false } })) : [])
] }
})
mcp.setRequestHandler(CallToolRequestSchema, async (call, extra) => {
if (call.params.name !== imageToolName) throw new Error('Unknown image tool')
const graphTool = storyGraphTools.find(tool => tool.name === call.params.name)
if (graphTool && this.descriptor.storyGraph) {
try { return await this.call(call.params.arguments ?? {}, graphTool.name) }
catch (error) { return { isError: true, content: [{ type: 'text', text: error instanceof Error ? error.message : 'Story Graph read failed' }] } }
}
if (call.params.name !== imageToolName || !this.descriptor.description) throw new Error('Unknown image tool')
const input = imageToolInputSchema.parse(call.params.arguments)
// Repeated delivery of an MCP request returns the same outcome, including unknown delivery.
const key = `${typeof extra.requestId}:${extra.requestId}`
@@ -87,8 +99,8 @@ export class AgentImageToolMcp {
const reply = remoteImageToolReplySchema.parse(decodeRemoteImageToolMessage(payload))
const pending = this.pending.get(reply.callId)
if (!pending) return
if (reply.error || !reply.result) pending.reject(new Error(reply.error ?? 'Image tool response has no result'))
else pending.resolve({ content: [{ type: 'text', text: JSON.stringify(reply.result) }] })
if (reply.error || (!reply.result && !reply.storyGraphResult)) pending.reject(new Error(reply.error ?? 'Tool response has no result'))
else pending.resolve({ content: [{ type: 'text', text: JSON.stringify(reply.storyGraphResult ?? reply.result) }] })
}
close(): void {
@@ -100,7 +112,7 @@ export class AgentImageToolMcp {
this.server?.close()
}
private async call(input: z.infer<typeof imageToolInputSchema>): Promise<CallToolResult> {
private async call(input: Record<string, unknown>, name?: StoryGraphToolName | 'story_graph_list'): Promise<CallToolResult> {
if (this.closed) throw new Error('Image tool prompt has ended')
const callId = randomUUID()
let timer: NodeJS.Timeout | undefined
@@ -110,7 +122,7 @@ export class AgentImageToolMcp {
timer.unref()
})
// Register the wait before sending; send failures must not cause a retry.
void this.send(encodeRemoteImageToolMessage({ callId, input })).catch(() => {
void this.send(encodeRemoteImageToolMessage({ callId, input, ...(name ? { name } : {}) })).catch(() => {
this.pending.get(callId)?.reject(new Error('Image tool delivery may be unknown. Do not retry automatically.'))
})
try { return await result } finally {
@@ -132,6 +132,9 @@ async function plugin(input?: {
permission.push(...(child.data.permission ?? []).filter(rule => rule.permission !== '*'))
}
permission.push({ permission: 'goodbuddy_image_*', pattern: '*', action: 'deny' })
if (route.imageToolName) {
permission.push({ permission: `${route.imageToolName}_story_graph_*`, pattern: '*', action: 'allow' })
}
if (route.workMode === 'execute' && route.imageToolName) {
permission.push({ permission: `${route.imageToolName}_*`, pattern: '*', action: 'allow' })
}
@@ -634,6 +634,36 @@ describe('RuntimeAcpBackend', () => {
expect(fixture.lifecycle).not.toContain('bridge-listen')
} finally { await fixture.backend.dispose() }
})
it('binds Story Graph Ask discovery and reads to authenticated blob frames', async () => {
const fixture = harness()
const client = new Client({ name: 'graph-backend-test', version: '1' })
try {
await open(fixture)
const accepted = await invoke(fixture, 'runtime/preparePrompt', fixture.preparation({
imageTool: { channelId: 'graph-channel', channelEpoch: '1', storyGraph: true }
})) as { imageToolUrl: string }
await client.connect(new StreamableHTTPClientTransport(new URL(accepted.imageToolUrl)))
const listing = client.listTools()
await vi.waitFor(() => expect(fixture.blobSink).toHaveBeenCalledOnce())
const reply = async (index: number, storyGraphResult: object) => {
const frame = fixture.blobSink.mock.calls[index]![0]
const call = remoteImageToolCallSchema.parse(decodeRemoteImageToolMessage(frame.payload))
const payload = encodeRemoteImageToolMessage({ callId: call.callId, storyGraphResult })
await fixture.backend.onBlobFrame({ header: { ...frame.header, direction: 'main-to-agent', payloadLength: payload.byteLength }, payload },
{ ...fixture.context, channelId: 'graph-channel' })
return call
}
expect(await reply(0, { available: true })).toMatchObject({ name: 'story_graph_list' })
expect((await listing).tools).toHaveLength(3)
const reading = client.callTool({ name: 'story_graph_search', arguments: { query: 'Decision' } })
await vi.waitFor(() => expect(fixture.blobSink).toHaveBeenCalledTimes(2))
expect(await reply(1, { items: [], coverage: { status: 'unknown' } })).toMatchObject({ name: 'story_graph_search', input: { query: 'Decision' } })
expect(await reading).not.toHaveProperty('isError', true)
await invoke(fixture, 'runtime/completePrompt', { bindingId: 'binding-1', operationId: 'request-1', requestId: 'request-1' })
await expect(fetch(accepted.imageToolUrl)).rejects.toThrow()
} finally { await client.close(); await fixture.backend.dispose() }
})
it.each([false, true])('allows mode changes only on a shared Session (shared=%s)', async (shared) => {
const fixture = harness({ agentOwned: true, shareOwnedProcesses: shared, workMode: 'execute' })
try {
+1 -1
View File
@@ -1173,7 +1173,7 @@ export class RuntimeAcpBackend {
}
}
}
if (preparation.imageTool && (preparation.workMode !== 'execute' || !this.#options.blobSink)) {
if (preparation.imageTool && ((preparation.imageTool.description && preparation.workMode !== 'execute') || !this.#options.blobSink)) {
throw new RuntimeAcpBackendError('Image tools require Execute and a desktop transport', 'identity')
}
if (requestedModelBridgePolicy !== undefined) {
@@ -0,0 +1,115 @@
// @vitest-environment node
import { randomUUID } from 'node:crypto'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import { afterEach, expect, it, vi } from 'vitest'
import { AssistantDatabase } from '../main/assistant/assistant-database'
import type { KnowledgeService } from '../main/knowledge/knowledge-service'
import { KnowledgeMcpGateway } from '../main/agent/knowledge-mcp-gateway'
import { ModelToolProvider } from '../main/agent/model-tool-provider'
import { AgentImageToolMcp } from './image-tool-mcp'
import { MainImageToolSession } from '../main/remote-agent/main-image-tool-session'
import type { RuntimeProtocolBinaryChannel, RuntimeProtocolBinaryFrame } from '../main/remote-agent/protocol-remote-runtime-channel'
import { storyGraphToolNames } from '../shared/story-graph-tools'
const cleanups: Array<() => void | Promise<void>> = []
afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup() })
function fixture() {
const db = new AssistantDatabase(':memory:'); db.initialize(process.cwd())
cleanups.push(() => db.close())
const projectId = db.listProjects()[0]!.id
db.saveSupervisionResult({ request: { trigger: 'manual', scope: { kind: 'projects', projectIds: [projectId] }, timeRange: { from: '2026-09-01T00:00:00Z', to: '2026-09-30T00:00:00Z' } },
evidence: [{ id: 'quote', sourceType: 'conversation', sourceId: 'original', title: 'Evidence', occurredAt: '2026-09-01T00:00:00Z', content: 'Decision A is revised to B, C remains only an option.' }],
output: { summary: 'Decision history', changeDigest: '', openItems: ['Not yet executed'], entities: [{ id: 'decision', label: 'Decision', description: 'Decision A revised to B', sourceReferenceIds: ['quote'] }],
events: [], relations: [], entityChanges: [] } })
let enabled = true, assigned = true
const read = vi.fn(db.readStoryGraph.bind(db))
const available = vi.fn(async () => enabled && assigned)
const gateway = new KnowledgeMcpGateway({} as KnowledgeService, { storyGraphService: { available, read } })
cleanups.push(() => gateway.dispose())
const wait = new AbortController()
const token = gateway.grant('graph', [], wait.signal, 'none', undefined, undefined, undefined, undefined, undefined, { projectId, runtimeTarget: 'model' })!
return { db, projectId, gateway, token, wait, read, available,
disable: () => { enabled = false }, unassign: () => { assigned = false } }
}
async function client(url: string, token?: string) {
const connection = new Client({ name: 'graph-test', version: '1' })
await connection.connect(new StreamableHTTPClientTransport(new URL(url), token ? { requestInit: { headers: { Authorization: `Bearer ${token}` } } } : undefined))
cleanups.push(() => connection.close())
return connection
}
it.each(['local', 'remote'] as const)('%s HTTP MCP reads SQLite through Main and revokes discovery and old calls when disabled', async transport => {
const f = fixture()
let connection: Client
if (transport === 'local') {
await f.gateway.start()
connection = await client(f.gateway.getEndpoint()!, f.token)
} else {
// Real Agent HTTP adapter, binary messages and Main dispatcher; only the wire carrier is in-process.
const queue: RuntimeProtocolBinaryFrame[] = []
let receive: ((frame: RuntimeProtocolBinaryFrame) => void) | undefined
let reject: ((error: Error) => void) | undefined
const channel: RuntimeProtocolBinaryChannel = {
channelId: randomUUID(), channelEpoch: '1',
send: async payload => adapter.onReply(payload),
receive: async () => queue.shift() ?? await new Promise((resolve, fail) => { receive = resolve; reject = fail }),
close: () => reject?.(new Error('closed')), onClose: () => () => {}
}
const adapter = new AgentImageToolMcp({ channelId: channel.channelId, channelEpoch: '1', storyGraph: true }, async payload => {
const frame = { payload, sequence: '1', consume: async () => {} }
if (receive) { const resolve = receive; receive = undefined; resolve(frame) } else queue.push(frame)
})
const main = new MainImageToolSession(channel, undefined, f.wait.signal, f.gateway.bindRemoteStoryGraph(f.token))
await adapter.start()
cleanups.push(() => { main.close(); adapter.close() })
connection = await client(adapter.url!)
}
const tools = (await connection.listTools()).tools
expect(tools.map(tool => tool.name)).toEqual(storyGraphToolNames)
expect(tools.every(tool => tool.annotations?.readOnlyHint)).toBe(true)
const search = await connection.callTool({ name: 'story_graph_search', arguments: { query: 'Decision', page_size: 1 } })
expect(search.isError).not.toBe(true)
const result = JSON.parse((search.content as { text: string }[])[0]!.text)
expect(result.resolved_scope).toEqual({ kind: 'projects', projectIds: [f.projectId] })
expect(result.page.has_more).toBe(true)
const context = await connection.callTool({ name: 'story_graph_get_context', arguments: { object_ref: result.items[0].object_ref, mode: 'timeline' } })
expect(context.isError).not.toBe(true)
const reference = (f.db.getSupervisionGraph({}).sources as { id: string }[])[0]!.id
const source = await connection.callTool({ name: 'story_graph_read_source', arguments: { source_reference_id: reference } })
expect(JSON.stringify(source)).toContain('revised to B')
const unsupported = await connection.callTool({ name: 'story_graph_get_context', arguments: { object_ref: result.items[0].object_ref, mode: 'as_of' } })
expect(unsupported.isError).toBe(true)
expect(JSON.stringify(unsupported)).toContain('unsupported_mode')
const before = f.read.mock.calls.length
f.disable()
expect((await connection.listTools()).tools).toEqual([])
const blocked = await connection.callTool({ name: 'story_graph_search', arguments: { query: 'Decision', cursor: result.page.next_cursor, page_size: 1 } })
expect(blocked.isError).toBe(true)
expect(f.read).toHaveBeenCalledTimes(before)
})
it.each(['ask', 'execute'] as const)('direct Model %s uses the same reader and runtime assignment gate', async workMode => {
const f = fixture()
const provider = new ModelToolProvider(process.cwd(), [], undefined, f.gateway)
cleanups.push(() => provider.dispose())
const context = { conversationId: 'graph', workMode, knowledgeCapabilityToken: f.token }
expect((await provider.listTools(context, f.wait.signal)).filter(tool => tool.name.startsWith('story_graph_')).map(tool => tool.name)).toEqual(storyGraphToolNames)
const result = await provider.callTool('story_graph_search', { query: 'Decision' }, f.wait.signal, context)
expect(JSON.stringify(result)).toContain(f.projectId)
f.unassign()
expect((await provider.listTools(context, f.wait.signal)).some(tool => tool.name.startsWith('story_graph_'))).toBe(false)
await expect(provider.callTool('story_graph_search', { query: 'Decision' }, f.wait.signal, context)).rejects.toThrow('story_graph_unavailable')
})
it('rejects disabled in-flight delivery and cancellation before the synchronous read', async () => {
const f = fixture()
f.available.mockImplementationOnce(async () => { f.wait.abort(); return true })
await expect(f.gateway.callStoryGraphTool(f.token, 'story_graph_search', { query: 'Decision' })).rejects.toThrow()
expect(f.read).not.toHaveBeenCalled()
const other = fixture()
other.read.mockImplementationOnce((...args) => { const result = other.db.readStoryGraph(...args); other.disable(); return result })
await expect(other.gateway.callStoryGraphTool(other.token, 'story_graph_search', { query: 'Decision' })).rejects.toThrow('story_graph_unavailable')
})