From dbcdf530f7f8a2c3f864ef52a9585f402803b643 Mon Sep 17 00:00:00 2001 From: Yuqing Yang Date: Fri, 4 Sep 2026 15:57:57 +0000 Subject: [PATCH 1/7] feat(agent): add Space Prompt Frames Compile explicitly authored prompt Frames into bounded, durable instructions for fixed Agent Nodes across built-in and ACP runtimes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/server/src/modules/agent/acp/service.ts | 10 +- .../agent/acp/service.workload-spec.test.ts | 26 ++ .../src/modules/agent/agenetes/pi-driver.ts | 5 + .../agent/agent-thread.service.test.ts | 83 ++++++ .../src/modules/agent/agent-thread.service.ts | 74 ++++- .../server/src/modules/agent/agent.service.ts | 4 + .../src/modules/agent/space-prompt.test.ts | 275 ++++++++++++++++++ apps/server/src/modules/agent/space-prompt.ts | 234 +++++++++++++++ apps/server/src/prompt/space-prompt.md | 14 + docs/architecture/agent-architecture.md | 4 +- docs/architecture/agent-context.md | 16 +- packages/shared/src/types/canvas/index.ts | 2 + packages/shared/src/types/canvas/node.ts | 21 ++ 13 files changed, 760 insertions(+), 8 deletions(-) create mode 100644 apps/server/src/modules/agent/space-prompt.test.ts create mode 100644 apps/server/src/modules/agent/space-prompt.ts create mode 100644 apps/server/src/prompt/space-prompt.md diff --git a/apps/server/src/modules/agent/acp/service.ts b/apps/server/src/modules/agent/acp/service.ts index 2437b4659..8f0dcb296 100644 --- a/apps/server/src/modules/agent/acp/service.ts +++ b/apps/server/src/modules/agent/acp/service.ts @@ -103,6 +103,8 @@ export interface RunAcpAgentOptions { cwd?: string; /** Per-node spawn overrides applied when the workload is first created. */ launchOverrides?: AgentLaunchOverrides; + /** Frozen Space Prompt captured when a fixed Agent Node is first realised. */ + spacePrompt?: string; /** Cancellation signal \u2014 wired through to `session/cancel`. */ signal?: AbortSignal; logger: FastifyBaseLogger; @@ -196,7 +198,12 @@ function applyWorkingDirectoryOverride( export function buildAcpWorkloadSpec( opts: Pick< RunAcpAgentOptions, - 'binding' | 'threadId' | 'canvasId' | 'cwd' | 'launchOverrides' + | 'binding' + | 'threadId' + | 'canvasId' + | 'cwd' + | 'launchOverrides' + | 'spacePrompt' >, ): AcpWorkloadSpec { const { binding, threadId } = opts; @@ -244,6 +251,7 @@ export function buildAcpWorkloadSpec( spec: { initialPreamble: [ renderExternalAgentSystemPreamble(), + ...(opts.spacePrompt ? [opts.spacePrompt] : []), ...(opts.launchOverrides?.additionalInitialPreamble ? [opts.launchOverrides.additionalInitialPreamble] : []), diff --git a/apps/server/src/modules/agent/acp/service.workload-spec.test.ts b/apps/server/src/modules/agent/acp/service.workload-spec.test.ts index f9aede8df..f428540fe 100644 --- a/apps/server/src/modules/agent/acp/service.workload-spec.test.ts +++ b/apps/server/src/modules/agent/acp/service.workload-spec.test.ts @@ -118,4 +118,30 @@ describe('buildAcpWorkloadSpec', () => { }, }); }); + + it('places Space instructions between bootstrap and node constraints', () => { + mocks.profile = { + id: 'profile-a', + alias: 'Researcher', + agentletId: 'agentlet-a', + workingDirPath: '/profile/work', + launch: { kind: 'acp-command', command: 'copilot --acp' }, + }; + + const workload = buildAcpWorkloadSpec({ + binding: { profileId: 'profile-a', alias: 'Researcher' }, + threadId: 'thread-a', + canvasId: 'canvas-a', + spacePrompt: 'Space rules', + launchOverrides: { + additionalInitialPreamble: 'Node constraints', + }, + }); + + expect(workload.spec.initialPreamble).toEqual([ + 'Mandatory preamble', + 'Space rules', + 'Node constraints', + ]); + }); }); diff --git a/apps/server/src/modules/agent/agenetes/pi-driver.ts b/apps/server/src/modules/agent/agenetes/pi-driver.ts index 29a4788bd..8dc50d2ae 100644 --- a/apps/server/src/modules/agent/agenetes/pi-driver.ts +++ b/apps/server/src/modules/agent/agenetes/pi-driver.ts @@ -42,6 +42,7 @@ interface HuabuPiHostContext { readonly origin?: NodeOrigin; readonly modelRole?: ModelRole; readonly hasImage?: boolean; + readonly spacePrompt?: string; } interface BuildHuabuPiWorkloadSpecOptions { @@ -58,6 +59,7 @@ interface BuildHuabuPiWorkloadSpecOptions { readonly origin?: NodeOrigin; readonly modelRole?: ModelRole; readonly hasImage?: boolean; + readonly spacePrompt?: string; } function getHuabuHostContext( @@ -80,6 +82,8 @@ function getHuabuHostContext( ? (obj.modelRole as ModelRole) : undefined, hasImage: typeof obj.hasImage === 'boolean' ? obj.hasImage : undefined, + spacePrompt: + typeof obj.spacePrompt === 'string' ? obj.spacePrompt : undefined, }; } @@ -174,6 +178,7 @@ export function buildHuabuPiWorkloadSpec( ...(options.hasImage !== undefined ? { hasImage: options.hasImage } : {}), + ...(options.spacePrompt ? { spacePrompt: options.spacePrompt } : {}), }, }, }; diff --git a/apps/server/src/modules/agent/agent-thread.service.test.ts b/apps/server/src/modules/agent/agent-thread.service.test.ts index 3d5232558..00ee0a7cf 100644 --- a/apps/server/src/modules/agent/agent-thread.service.test.ts +++ b/apps/server/src/modules/agent/agent-thread.service.test.ts @@ -12,6 +12,7 @@ import { AgentThreadBusyError, AgentThreadService, externalBindingFromWorkloadSpec, + spacePromptFromWorkloadSpec, } from './agent-thread.service.js'; import type { FixedAgentNodeTarget } from './agent-thread-resolver.js'; @@ -79,6 +80,8 @@ function createHarness(options?: { startError?: Error; finishError?: Error; persistedBinding?: Extract | null; + persistedSpacePrompt?: { realised: boolean; markdown?: string }; + collectedSpacePrompt?: string; }) { const release = vi.fn(); const startLifecycle = options?.startError @@ -105,6 +108,17 @@ function createHarness(options?: { } return emptyInternalStream(); }); + const collectSpacePrompt = vi.fn().mockResolvedValue({ + markdown: options?.collectedSpacePrompt ?? 'Space prompt', + diagnostics: { + includedFrameIds: ['frame-prompt'], + includedNodeIds: ['text-prompt'], + omittedUnsupportedIds: [], + omittedEmptyTextIds: [], + omittedMissingIds: [], + truncated: false, + }, + }); const service = new AgentThreadService({ resolveFixedAgentNode: async () => options && 'target' in options ? (options.target ?? null) : TARGET, @@ -112,6 +126,9 @@ function createHarness(options?: { options && 'persistedBinding' in options ? (options.persistedBinding ?? null) : null, + resolvePersistedSpacePrompt: () => + options?.persistedSpacePrompt ?? { realised: false }, + collectSpacePrompt, waitForTurnRelease: vi.fn().mockResolvedValue(undefined), acquireTurn: vi.fn(() => (options?.busy ? null : release)), startLifecycle, @@ -129,6 +146,7 @@ function createHarness(options?: { failLifecycle, runExternal, runInternal, + collectSpacePrompt, }; } @@ -161,9 +179,32 @@ describe('AgentThreadService', () => { profileId: 'profile-a', alias: 'Researcher', }); + expect(externalBindingFromWorkloadSpec({ binding: {} })).toBeNull(); }); + it('reads internal and external Space Prompt snapshots from workload specs', () => { + expect( + spacePromptFromWorkloadSpec({ + hostContext: { + spacePrompt: 'Internal', + }, + }), + ).toBe('Internal'); + expect( + spacePromptFromWorkloadSpec({ + initialPreamble: [ + 'Bootstrap', + 'External', + 'Node constraints', + ], + }), + ).toBe('External'); + expect( + spacePromptFromWorkloadSpec({ initialPreamble: ['Bootstrap'] }), + ).toBeUndefined(); + }); + it('resolves a persisted external Thread without a fixed Agent Node', async () => { const binding = { kind: 'external' as const, @@ -210,6 +251,7 @@ describe('AgentThreadService', () => { expect.objectContaining({ binding: TARGET.agentBinding, launchOverrides: TARGET.launchOverrides, + spacePrompt: 'Space prompt', }), ); expect(harness.finishLifecycle).toHaveBeenCalledWith(TARGET); @@ -283,10 +325,49 @@ describe('AgentThreadService', () => { 'Review before making changes.', ), }), + spacePrompt: 'Space prompt', }), ); }); + it('reuses a realised Agent Node prompt snapshot without recollecting', async () => { + const harness = createHarness({ + persistedSpacePrompt: { + realised: true, + markdown: 'Original rules', + }, + }); + const invocation = await harness.service.invoke(invocationOptions()); + + for await (const _event of invocation.events) { + // Drain the canonical invocation stream. + } + + expect(harness.collectSpacePrompt).not.toHaveBeenCalled(); + expect(harness.runExternal).toHaveBeenCalledWith( + expect.objectContaining({ + spacePrompt: 'Original rules', + }), + ); + }); + + it('does not collect a Space Prompt for a non-fixed thread', async () => { + const harness = createHarness({ target: null }); + const invocation = await harness.service.invoke({ + ...invocationOptions(), + fixedTarget: null, + }); + + for await (const _event of invocation.events) { + // Drain the canonical invocation stream. + } + + expect(harness.collectSpacePrompt).not.toHaveBeenCalled(); + expect(harness.runExternal).toHaveBeenCalledWith( + expect.objectContaining({ spacePrompt: undefined }), + ); + }); + it('does not dispatch when the start lifecycle patch fails', async () => { const harness = createHarness({ startError: new Error('Canvas update failed'), @@ -358,6 +439,8 @@ describe('AgentThreadService', () => { const service = new AgentThreadService({ resolveFixedAgentNode: async () => TARGET, resolvePersistedExternalBinding: () => null, + resolvePersistedSpacePrompt: () => ({ realised: false }), + collectSpacePrompt: vi.fn().mockResolvedValue(undefined), waitForTurnRelease: vi.fn().mockResolvedValue(undefined), acquireTurn: vi.fn(() => vi.fn()), startLifecycle, diff --git a/apps/server/src/modules/agent/agent-thread.service.ts b/apps/server/src/modules/agent/agent-thread.service.ts index 64075e3de..37d2cada7 100644 --- a/apps/server/src/modules/agent/agent-thread.service.ts +++ b/apps/server/src/modules/agent/agent-thread.service.ts @@ -16,12 +16,14 @@ import { runAgent } from './agent.service.js'; import { envelopeHasImage } from './conversation/envelope.js'; import { readWorkspaceMemory } from './memory/index.js'; import { planSkillDispatch } from './skill-model-routing.js'; +import { resolveSpacePrompt } from './space-prompt.js'; import { acquireAgentTurn, waitForAgentTurnRelease } from './turn-lease.js'; import { loadAgent } from '../../prompt/index.js'; import { canvasAcpNamespace } from '../workspace/paths.js'; import type { HuabuSubmission } from './agenetes/handle.js'; import type { ChatEnvelope } from './conversation/envelope.js'; +import type { RenderedSpacePrompt } from './space-prompt.js'; import type { AgentBinding, AgentMode, @@ -39,6 +41,11 @@ interface AgentThreadServiceDependencies { canvasId: string, threadId: string, ) => Extract | null; + resolvePersistedSpacePrompt: ( + canvasId: string, + threadId: string, + ) => { realised: boolean; markdown?: string }; + collectSpacePrompt: (canvasId: string) => Promise; waitForTurnRelease: typeof waitForAgentTurnRelease; acquireTurn: typeof acquireAgentTurn; startLifecycle: typeof agentNodeLifecycle.start; @@ -62,6 +69,22 @@ export function externalBindingFromWorkloadSpec( return parsed.success && parsed.data.kind === 'external' ? parsed.data : null; } +export function spacePromptFromWorkloadSpec(spec: unknown): string | undefined { + if (!spec || typeof spec !== 'object') return undefined; + const value = spec as Record; + const hostContext = value.hostContext; + if (hostContext && typeof hostContext === 'object') { + const prompt = (hostContext as Record).spacePrompt; + if (typeof prompt === 'string') return prompt; + } + const preamble = value.initialPreamble; + if (!Array.isArray(preamble)) return undefined; + return preamble.find( + (entry): entry is string => + typeof entry === 'string' && entry.startsWith(''), + ); +} + const DEFAULT_DEPENDENCIES: AgentThreadServiceDependencies = { resolveFixedAgentNode: (canvasId, threadId) => agentThreadResolver.resolveFixedAgentNode(canvasId, threadId), @@ -70,6 +93,13 @@ const DEFAULT_DEPENDENCIES: AgentThreadServiceDependencies = { if (!record || record.spec.kind !== EXTERNAL_DRIVER_KIND) return null; return externalBindingFromWorkloadSpec(record.spec.spec); }, + resolvePersistedSpacePrompt: (canvasId, threadId) => { + const record = agenetes.record(canvasAcpNamespace(canvasId), threadId); + if (!record) return { realised: false }; + const markdown = spacePromptFromWorkloadSpec(record.spec.spec); + return markdown ? { realised: true, markdown } : { realised: true }; + }, + collectSpacePrompt: resolveSpacePrompt, waitForTurnRelease: waitForAgentTurnRelease, acquireTurn: acquireAgentTurn, startLifecycle: agentNodeLifecycle.start.bind(agentNodeLifecycle), @@ -112,7 +142,7 @@ export interface AgentThreadInvocationOptions { type EffectiveAgentThreadInvocationOptions = Omit< AgentThreadInvocationOptions, 'signal' -> & { signal: AbortSignal }; +> & { signal: AbortSignal; spacePrompt?: string }; export interface AgentThreadInvocation { binding: AgentBinding; @@ -138,15 +168,16 @@ function buildAgentSystemPrompt(params: { canvasId: string | undefined; mode: Parameters[0]; additionalInitialPreamble?: string; + spacePrompt?: string; }): string { const agentCfg = loadAgent(params.mode, { canvasId: params.canvasId }); const workspaceMemory = readWorkspaceMemory(); const base = workspaceMemory ? `${agentCfg.systemPrompt}\n\n\n${workspaceMemory}\n` : agentCfg.systemPrompt; - return params.additionalInitialPreamble - ? `${base}\n\n${params.additionalInitialPreamble}` - : base; + return [base, params.spacePrompt, params.additionalInitialPreamble] + .filter((part): part is string => Boolean(part)) + .join('\n\n'); } function errorMessage(error: unknown): string { @@ -222,7 +253,38 @@ export class AgentThreadService { }; this.activeInvocations.set(options.threadId, active); + let spacePrompt: string | undefined; try { + if (fixedTarget && options.canvasId) { + const persisted = this.dependencies.resolvePersistedSpacePrompt( + options.canvasId, + options.threadId, + ); + if (persisted.realised) { + spacePrompt = persisted.markdown; + } else { + const collected = await this.dependencies.collectSpacePrompt( + options.canvasId, + ); + spacePrompt = collected?.markdown; + if ( + collected && + (collected.diagnostics.truncated || + collected.diagnostics.omittedUnsupportedIds.length > 0 || + collected.diagnostics.omittedEmptyTextIds.length > 0 || + collected.diagnostics.omittedMissingIds.length > 0) + ) { + options.logger.warn( + { + canvasId: options.canvasId, + threadId: options.threadId, + spacePromptDiagnostics: collected.diagnostics, + }, + 'Space Prompt collection completed with diagnostics', + ); + } + } + } if (fixedTarget) { await this.dependencies.startLifecycle(fixedTarget, options.content); } @@ -238,6 +300,7 @@ export class AgentThreadService { const effectiveOptions: EffectiveAgentThreadInvocationOptions = { ...options, signal, + spacePrompt, }; let settled = false; @@ -370,6 +433,7 @@ export class AgentThreadService { ...(fixedTarget?.launchOverrides ? { launchOverrides: fixedTarget.launchOverrides } : {}), + spacePrompt: options.spacePrompt, signal: options.signal, logger: options.logger, debugPrompt: options.debugPrompt, @@ -399,11 +463,13 @@ export class AgentThreadService { mode: options.mode, additionalInitialPreamble: fixedTarget?.launchOverrides?.additionalInitialPreamble, + spacePrompt: options.spacePrompt, }), messages: [], tools: [], }, modelId: options.modelId, + spacePrompt: options.spacePrompt, reasoningEffort: options.reasoningEffort, maxIterations: 20, signal: options.signal, diff --git a/apps/server/src/modules/agent/agent.service.ts b/apps/server/src/modules/agent/agent.service.ts index c5e9b3845..a21330352 100644 --- a/apps/server/src/modules/agent/agent.service.ts +++ b/apps/server/src/modules/agent/agent.service.ts @@ -125,6 +125,8 @@ export interface AgentRunOptions { modelRole?: ModelRole; /** Whether this workload may send image content to the selected model. */ hasImage?: boolean; + /** Frozen Space Prompt captured when a fixed Agent Node is first realised. */ + spacePrompt?: string; /** * Per-thread model override id carried with this turn (built-in chat). * Applied to the thread before the run, so a model picked before the @@ -201,6 +203,7 @@ export async function* runAgent( origin, modelRole, hasImage, + spacePrompt, modelId, reasoningEffort, maxIterations, @@ -275,6 +278,7 @@ export async function* runAgent( origin, modelRole, hasImage, + spacePrompt, }); // Static DriverMap construction guarantees that `internal` is the diff --git a/apps/server/src/modules/agent/space-prompt.test.ts b/apps/server/src/modules/agent/space-prompt.test.ts new file mode 100644 index 000000000..146f69d09 --- /dev/null +++ b/apps/server/src/modules/agent/space-prompt.test.ts @@ -0,0 +1,275 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { describe, expect, it } from 'vitest'; + +import { renderSpacePrompt, SPACE_PROMPT_MAX_BYTES } from './space-prompt.js'; + +import type { CanvasFile, NodeContent } from '../storage/index.js'; +import type { NodeSnapshot } from '../storage/ports/structured.js'; + +function canvas(nodes: CanvasFile['state']['nodes']): CanvasFile { + return { + canvasId: 'canvas-a', + title: 'Canvas A', + version: 2, + state: { nodes, edges: [] }, + createdAt: 1, + updatedAt: 2, + } as CanvasFile; +} + +function records( + values: Array, +): Map { + return new Map( + values.map((record) => [ + record.nodeId, + { record, revision: `storage-${record.nodeId}` }, + ]), + ); +} + +describe('renderSpacePrompt', () => { + it('recognises explicitly authored prompt modules only', () => { + const result = renderSpacePrompt( + canvas([ + { + id: 'frame-user', + type: 'frame', + position: { x: 0, y: 0 }, + data: {}, + }, + { + id: 'frame-agent', + type: 'frame', + position: { x: 0, y: 200 }, + data: {}, + }, + { + id: 'frame-auto', + type: 'frame', + position: { x: 0, y: 400 }, + data: {}, + }, + { + id: 'text-user', + type: 'text', + parentId: 'frame-user', + position: { x: 0, y: 0 }, + data: {}, + }, + { + id: 'text-agent', + type: 'text', + parentId: 'frame-agent', + position: { x: 0, y: 0 }, + data: {}, + }, + { + id: 'text-auto', + type: 'text', + parentId: 'frame-auto', + position: { x: 0, y: 0 }, + data: {}, + }, + ]), + records([ + { + nodeId: 'frame-user', + type: 'frame', + label: ' Prompt ', + labelSource: 'user', + content: '', + }, + { + nodeId: 'frame-agent', + type: 'frame', + label: 'PROMPT: Review', + labelSource: 'agent', + content: '', + }, + { + nodeId: 'frame-auto', + type: 'frame', + label: 'prompt: ignored', + labelSource: 'auto', + content: '', + }, + { + nodeId: 'text-user', + type: 'text', + label: null, + content: 'User module', + }, + { + nodeId: 'text-agent', + type: 'text', + label: null, + content: 'Agent module', + }, + { + nodeId: 'text-auto', + type: 'text', + label: null, + content: 'Must not appear', + }, + ]), + ); + + expect(result?.markdown).toContain('User module'); + expect(result?.markdown).toContain('Agent module'); + expect(result?.markdown).not.toContain('Must not appear'); + expect(result?.diagnostics.includedFrameIds).toEqual([ + 'frame-user', + 'frame-agent', + ]); + }); + + it('renders direct Text and lazy Note references in stable reading order', () => { + const result = renderSpacePrompt( + canvas([ + { + id: 'frame', + type: 'frame', + position: { x: 10, y: 10 }, + data: {}, + }, + { + id: 'note-b', + type: 'note', + parentId: 'frame', + position: { x: 10, y: 10 }, + data: {}, + }, + { + id: 'text-a', + type: 'text', + parentId: 'frame', + position: { x: 10, y: 10 }, + data: {}, + }, + { + id: 'nested-frame', + type: 'frame', + parentId: 'frame', + position: { x: 0, y: 20 }, + data: {}, + }, + { + id: 'nested-text', + type: 'text', + parentId: 'nested-frame', + position: { x: 0, y: 0 }, + data: {}, + }, + { + id: 'image', + type: 'image', + parentId: 'frame', + position: { x: 0, y: 30 }, + data: {}, + }, + ]), + records([ + { + nodeId: 'frame', + type: 'frame', + label: 'prompt: Module', + labelSource: 'user', + content: '', + }, + { + nodeId: 'text-a', + type: 'text', + label: null, + content: 'First instruction', + }, + { + nodeId: 'note-b', + type: 'note', + label: 'Reference & guide', + content: 'Lazy body must not be injected', + }, + { + nodeId: 'nested-frame', + type: 'frame', + label: 'Nested', + content: '', + }, + { + nodeId: 'nested-text', + type: 'text', + label: null, + content: 'Nested content', + }, + { + nodeId: 'image', + type: 'image', + label: 'Image', + content: '', + }, + ]), + ); + + expect(result).not.toBeNull(); + if (!result) throw new Error('Expected a rendered Space Prompt'); + expect(result.markdown.indexOf('/, + ); + expect(result.markdown).not.toContain('Lazy body must not be injected'); + expect(result.markdown).not.toContain('Nested content'); + expect(result.diagnostics.omittedUnsupportedIds).toEqual([ + 'nested-frame', + 'image', + ]); + }); + + it('bounds the complete prompt without splitting Unicode code points', () => { + const result = renderSpacePrompt( + canvas([ + { + id: 'frame', + type: 'frame', + position: { x: 0, y: 0 }, + data: {}, + }, + { + id: 'text', + type: 'text', + parentId: 'frame', + position: { x: 0, y: 0 }, + data: {}, + }, + ]), + records([ + { + nodeId: 'frame', + type: 'frame', + label: 'prompt', + labelSource: 'user', + content: '', + }, + { + nodeId: 'text', + type: 'text', + label: null, + content: "LEAD$'MID$`TAIL$&" + '๐Ÿ™‚'.repeat(10_000), + }, + ]), + ); + + if (!result) throw new Error('Expected a rendered Space Prompt'); + expect(Buffer.byteLength(result.markdown, 'utf8')).toBeLessThanOrEqual( + SPACE_PROMPT_MAX_BYTES, + ); + expect(result.markdown).not.toContain('\uFFFD'); + expect(result.markdown).toContain('</space_prompt>'); + expect(result.markdown.match(/<\/space_prompt>/g)).toHaveLength(1); + expect(result.markdown).toContain('Space Prompt truncated'); + expect(result.diagnostics.truncated).toBe(true); + }); +}); diff --git a/apps/server/src/modules/agent/space-prompt.ts b/apps/server/src/modules/agent/space-prompt.ts new file mode 100644 index 000000000..2b8e04ac8 --- /dev/null +++ b/apps/server/src/modules/agent/space-prompt.ts @@ -0,0 +1,234 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { isPromptFrame } from '@huabu/shared'; +import { nodeRevisionOf } from '@huabu/shared/canvas-engine'; + +import { buildAgentNodeRef } from './node-ref.js'; +import { renderPromptFile } from '../../prompt/agents/loader.js'; +import { buildSpatialBundle } from '../canvas/canvas-spatial.js'; +import { space } from '../storage/index.js'; + +import type { CanvasFile, NodeContent } from '../storage/index.js'; +import type { CanvasNode } from '@huabu/shared/canvas-engine'; + +export const SPACE_PROMPT_MAX_BYTES = 16 * 1024; + +export interface SpacePromptDiagnostics { + readonly includedFrameIds: readonly string[]; + readonly includedNodeIds: readonly string[]; + readonly omittedUnsupportedIds: readonly string[]; + readonly omittedEmptyTextIds: readonly string[]; + readonly omittedMissingIds: readonly string[]; + readonly truncated: boolean; +} + +export interface RenderedSpacePrompt { + readonly markdown: string; + readonly diagnostics: SpacePromptDiagnostics; +} + +interface OrderedNode { + readonly raw: CanvasNode; + readonly x: number; + readonly y: number; +} + +function compareReadingOrder(a: OrderedNode, b: OrderedNode): number { + return ( + a.y - b.y || + a.x - b.x || + (a.raw.id < b.raw.id ? -1 : a.raw.id > b.raw.id ? 1 : 0) + ); +} + +function recordLabel(record: NodeContent): string { + return typeof record.label === 'string' ? record.label : ''; +} + +function quoteAttribute(value: string): string { + return value + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); +} + +function neutralizeSpacePromptTags(value: string): string { + return value.replace(/<\/?space_prompt>/gi, (tag) => `<${tag.slice(1)}`); +} + +function renderNoteReference(node: CanvasNode, record: NodeContent): string { + const ref = buildAgentNodeRef({ + id: node.id, + type: 'note', + label: recordLabel(record), + }); + const rev = nodeRevisionOf({ + content: record.content, + ...(typeof record.src === 'string' ? { src: record.src } : {}), + }); + return ``; +} + +function truncateUtf8(value: string, byteLimit: number): string { + if (Buffer.byteLength(value, 'utf8') <= byteLimit) return value; + let used = 0; + let output = ''; + for (const char of value) { + const bytes = Buffer.byteLength(char, 'utf8'); + if (used + bytes > byteLimit) break; + output += char; + used += bytes; + } + return output; +} + +function renderWithinBudget( + content: string, + diagnostics: Omit, +): RenderedSpacePrompt { + const omissionDiagnostics = [ + diagnostics.omittedUnsupportedIds.length > 0 + ? `- Omitted unsupported direct children: ${diagnostics.omittedUnsupportedIds.length}.` + : '', + diagnostics.omittedEmptyTextIds.length > 0 + ? `- Omitted empty Text nodes: ${diagnostics.omittedEmptyTextIds.length}.` + : '', + diagnostics.omittedMissingIds.length > 0 + ? `- Omitted missing node records: ${diagnostics.omittedMissingIds.length}.` + : '', + ] + .filter(Boolean) + .join('\n'); + const complete = renderPromptFile('space-prompt.md', { + content, + diagnostics: omissionDiagnostics, + }); + if (Buffer.byteLength(complete, 'utf8') <= SPACE_PROMPT_MAX_BYTES) { + return { + markdown: complete, + diagnostics: { ...diagnostics, truncated: false }, + }; + } + + const marker = '\n\n[Space Prompt truncated at the 16 KiB injection limit.]'; + const truncatedDiagnostics = [ + omissionDiagnostics, + '- Some Prompt Frame content was truncated.', + ] + .filter(Boolean) + .join('\n'); + const shell = renderPromptFile('space-prompt.md', { + content: '{{SPACE_PROMPT_CONTENT}}', + diagnostics: truncatedDiagnostics, + }); + const available = + SPACE_PROMPT_MAX_BYTES - + Buffer.byteLength(shell.replace('{{SPACE_PROMPT_CONTENT}}', '') + marker); + const boundedContent = truncateUtf8(content, Math.max(0, available)); + return { + markdown: shell.replace( + '{{SPACE_PROMPT_CONTENT}}', + () => `${boundedContent}${marker}`, + ), + diagnostics: { ...diagnostics, truncated: true }, + }; +} + +export function renderSpacePrompt( + canvas: CanvasFile, + records: ReadonlyMap, +): RenderedSpacePrompt | null { + const bundle = buildSpatialBundle(canvas); + const frames = bundle.spatialNodes + .filter((node) => { + const raw = bundle.rawById.get(node.id); + const record = records.get(node.id)?.record; + return ( + raw?.type === 'frame' && + isPromptFrame(record?.label, record?.labelSource) + ); + }) + .flatMap((node) => { + const raw = bundle.rawById.get(node.id); + return raw ? [{ raw, x: node.rect.x, y: node.rect.y }] : []; + }) + .sort(compareReadingOrder); + + if (frames.length === 0) return null; + + const includedNodeIds: string[] = []; + const omittedUnsupportedIds: string[] = []; + const omittedEmptyTextIds: string[] = []; + const omittedMissingIds: string[] = []; + const sections: string[] = []; + + for (const frame of frames) { + const frameRecord = records.get(frame.raw.id)?.record; + if (!frameRecord) continue; + const children = [...bundle.rawById.values()] + .filter((node) => node.parentId === frame.raw.id) + .map((raw) => ({ + raw, + x: raw.position.x, + y: raw.position.y, + })) + .sort(compareReadingOrder); + const entries: string[] = []; + + for (const child of children) { + if (child.raw.type !== 'text' && child.raw.type !== 'note') { + omittedUnsupportedIds.push(child.raw.id); + continue; + } + const record = records.get(child.raw.id)?.record; + if (!record) { + omittedMissingIds.push(child.raw.id); + continue; + } + if (child.raw.type === 'text') { + if (!record.content.trim()) { + omittedEmptyTextIds.push(child.raw.id); + continue; + } + entries.push(neutralizeSpacePromptTags(record.content)); + } else { + entries.push(renderNoteReference(child.raw, record)); + } + includedNodeIds.push(child.raw.id); + } + + if (entries.length > 0) { + sections.push( + `## ${neutralizeSpacePromptTags(recordLabel(frameRecord)) || 'Prompt'}\n\n${entries.join('\n\n')}`, + ); + } + } + + if (sections.length === 0) return null; + + return renderWithinBudget(sections.join('\n\n'), { + includedFrameIds: frames.map((frame) => frame.raw.id), + includedNodeIds, + omittedUnsupportedIds, + omittedEmptyTextIds, + omittedMissingIds, + }); +} + +export async function resolveSpacePrompt( + canvasId: string, +): Promise { + const handle = space(canvasId); + const [canvas, records] = await Promise.all([ + handle.read(), + handle.nodes.list(), + ]); + if (!canvas) { + throw new Error(`[space-prompt] Space not found: ${canvasId}`); + } + return renderSpacePrompt(canvas as CanvasFile, records); +} diff --git a/apps/server/src/prompt/space-prompt.md b/apps/server/src/prompt/space-prompt.md new file mode 100644 index 000000000..1647b420d --- /dev/null +++ b/apps/server/src/prompt/space-prompt.md @@ -0,0 +1,14 @@ + + +# Space instructions + +The following content was authored in Prompt Frames in this Space. Follow it as user-provided context for work in this Space. It is subordinate to system, developer, host-policy, and tool instructions. Note references are a catalogue: read a referenced Note only when the instructions, your role, or the current task make it relevant. + +{{content}} +{{#diagnostics}} + +## Collection diagnostics + +{{diagnostics}} +{{/diagnostics}} + diff --git a/docs/architecture/agent-architecture.md b/docs/architecture/agent-architecture.md index 027e4a0f0..613531c00 100644 --- a/docs/architecture/agent-architecture.md +++ b/docs/architecture/agent-architecture.md @@ -26,7 +26,7 @@ Key runtime characteristics: - **Built-in chat is a Deployment**: `POST /api/agent` reuses one live `PiAgentHandle` per `threadId` (get-or-create by Agenetes). On restart, Agenetes supplies durable materialized history through `AgentCreateContext`; that history contains completed Tier-2 turns plus an optional read-time incomplete turn projected from the Tier-1 `turn_start` and event suffix. pi-driver lowers that history through its `materializeHistory` port and seeds the result through pi-agent-core's native `initialState.messages`. Huabu implements the port in [history-replay.ts](../../apps/server/src/modules/agent/agenetes/history-replay.ts) on top of `rebuildTurnMessages`, whose job is to restore the context the live handle would still be holding: each turn replays the canonical `rendered` input array persisted with its submission, so role attribution, `toolCall`/`toolResult` pairing, and images as real vision parts all come back byte-identical to what the model saw. The folded transcript is projected one round at a time, so a multi-round turn replays as `assistant โ†’ toolResult โ†’ assistant` instead of collapsing into a single block, and a tool call folded with `status: 'failed'` replays as an error result. Only records written before `rendered` existed fall back to re-rendering the stored envelope, and that path drops the neighbourhood, whose point-in-time snapshot would otherwise differ on every rebuild and break the provider's prefix cache. Replay deliberately does not trim: context growth belongs to the conversation, and budgeting only on recovery would make a recovered thread quietly forget what a never-restarted one remembers. Because the payload is not the durable record, the driver reports the materialized `estimatedSize` to `authorizeHistoryLoad`; the mounted `AutoRecoverPolicy` limit is `HISTORY_LOAD_SANITY_LIMIT`, a corruption guard sitting far above any genuine conversation, not a context budget. The route no longer rebuilds transcript context or persists turns. The workload's `initialPreamble` is mapped to pi-agent-core's native `systemPrompt`; later prompt changes use native `set_context`. The pi driver also re-resolves the symbolic `{ type: 'host', id: 'active' }` model ref at every turn boundary. - **RFS Agent creation and prompting are separate**: `POST /agent` creates a visible Agent Node and may start its first turn, while `POST /agent/:threadId/prompt` addresses an existing conversation. Both Huabu and configured Agent Profiles use the same node-backed invocation service; turns continue draining after the RFS socket disconnects and remain stoppable through the shared explicit stop path. - **Deployment turns are mutually exclusive**: `AgentThreadService` owns the shared per-`threadId` turn lease, abort controller, process-local active-invocation registry, and durable-turn-start barrier for UI, RFS, and Interactive View invocation. The lease remains held until the run settles, including when a client disconnects. `GET /api/agent/stream/:threadId` validates the active invocation's owner Canvas and independently tails Agenetes Tier 1, so an RFS response and multiple Web tabs can observe one turn without draining each other. History reads include the uncovered Tier-1 suffix and wait for turn start when the matching invocation is active. -- **UI invocation is service-owned**: `POST /api/agent` delegates dispatch and lease ownership to `AgentThreadService`. When `(canvasId, threadId)` resolves to a fixed Agent Node, the persisted external binding overrides request binding data, the node's launch overrides feed first ACP realization, and the service owns first content plus running/done/error Canvas patches. Selectable Question Nodes and ordinary Canvas Chat retain their existing request binding and Web lifecycle paths. +- **UI invocation is service-owned**: `POST /api/agent` delegates dispatch and lease ownership to `AgentThreadService`. When `(canvasId, threadId)` resolves to a fixed Agent Node, the persisted external binding overrides request binding data, the node's launch overrides feed first ACP realization, and the service owns first content plus running/done/error Canvas patches. On first realization the service also compiles the Space's recognized Prompt Frames into a bounded user-authored preamble and persists that snapshot in the workload (`hostContext.spacePrompt` for the built-in driver, a dedicated `initialPreamble` fragment for ACP); later turns reuse the snapshot, including the intentional absence of a prompt on an already-realized legacy thread. Selectable Question Nodes and ordinary Canvas Chat retain their existing request binding and Web lifecycle paths and never trigger Prompt Frame collection. - **Abort**: route `signal` โ†’ `agent.abort()`; pi-agent-core writes a final message with `stopReason: 'aborted'`. ACP turns check the same signal both before and after session bootstrap, so stopping during process startup never dispatches the pending `session/prompt`. A replacement `/api/agent` request waits (bounded) for any in-flight turn on the same thread to release its lease before acquiring โ€” this absorbs the cancel-then-resend race where the client's fire-and-forget `/stop` has not yet reached the server. A turn that never releases within the timeout, or a genuinely concurrent turn, still receives `409 thread_busy`. - **Headless source conversations**: the visible Canvas does not determine execution ownership. A World `nodeRef` presentation routes history, reconnect, `/api/agent`, tools, and change records through the source question's Canvas/thread and uses the source question as `anchorNodeId`. Lifecycle patches use the existing server Canvas executor against that owner, so the active World store never authors source status. @@ -127,7 +127,7 @@ Chat context uses an **envelope-first submission boundary** (see [agent-context. [acp/](../../apps/server/src/modules/agent/acp) is the integration layer for external agents. Its trusted built-in catalogue detects and launches GitHub Copilot, Claude Agent, Gemini, Codex, Qwen Code, Kimi Code CLI, OpenCode, Cursor, and Hermes Agent; Manual setup remains available for other ACP-compatible agents and advanced launch commands. Presets with official argument-based full-auto modes expose an auto-approve toggle whose structured recipe controls both the arguments and whether global options precede the ACP subcommand; agents that require environment variables, configuration, or ACP session modes do not expose this launch-command toggle. -- [service.ts](../../apps/server/src/modules/agent/acp/service.ts) `runAcpAgent()` is the external counterpart of `runAgent`: it performs host rendering, constructs the submission, snapshots a unified Agent Profile for a new thread, and drives one Agenetes turn. A fixed Agent Node may supply bounded launch overrides: `workingDirPath` replaces the effective workload cwd and the cwd embedded in command or manifest recipes, while `additionalInitialPreamble` follows the mandatory Huabu external-agent preamble. Agenetes keeps an already persisted WorkloadSpec authoritative, so later calls cannot mutate a realized thread's launch identity. +- [service.ts](../../apps/server/src/modules/agent/acp/service.ts) `runAcpAgent()` is the external counterpart of `runAgent`: it performs host rendering, constructs the submission, snapshots a unified Agent Profile for a new thread, and drives one Agenetes turn. A fixed Agent Node may supply bounded launch overrides: `workingDirPath` replaces the effective workload cwd and the cwd embedded in command or manifest recipes, while the frozen Space Prompt follows the mandatory Huabu external-agent preamble and `additionalInitialPreamble` follows the Space Prompt. Agenetes keeps an already persisted WorkloadSpec authoritative, so later calls cannot mutate a realized thread's launch identity or Space instructions. - [preprocessor.ts](../../apps/server/src/modules/agent/acp/preprocessor.ts) renders the shared `ChatEnvelope` into canonical `AgentInput[]`. Slash commands become one exclusive `AgentCommandInput`; selection and attachments ride its `context`. - [`@agenetes/agentlet-host`](../../external/agenetes/packages/agentlet-host) mounts the durably stateless [`@agenetes/agentlet-gateway`](../../external/agenetes/packages/agentlet-gateway), supervises the local agentlet daemon, and injects host-owned authentication. The Gateway owns only live control/session connections, pending RPCs, reconnect buffers, and bounded pre-attach buffering; durable workload and conversation state remains in Agenetes. Ordinary control RPCs time out after 60 seconds, while `server/spawn` has a separate 240-second deadline because it includes ACP `initialize` plus session lifecycle bootstrap, whose two sequential requests may each take up to 90 seconds. - [`@agenetes/agent-team`](../../external/agenetes/packages/agent-team) owns the unified Profile registry, Profile schemas, setup state, and manifest-runtime resolution. For current Huabu external chat, `runAcpAgent()` reads the selected Profile and compiles its non-sensitive placement and launch identity in the host composition layer into the canonical opaque ACP spec before calling Agenetes. Command Profiles become concrete command recipes; manifest Profiles become concrete Agent Team recipe references. diff --git a/docs/architecture/agent-context.md b/docs/architecture/agent-context.md index 904db19d7..b76646051 100644 --- a/docs/architecture/agent-context.md +++ b/docs/architecture/agent-context.md @@ -43,7 +43,7 @@ POST /api/agent (agent.route.ts) --- -## 3. Prompt-level injection: workspace memory + skills +## 3. Prompt-level injection: workspace memory + skills + Space Prompt These two are **cross-turn-stable** system-prompt injections (they don't change per turn), kept separate from the per-turn focus signals in ยง4. @@ -62,6 +62,20 @@ Skills are **not tools**; they reach the prompt via two complementary paths: The catalogue is filtered by the agent's frontmatter `skillScope` (ask/operate/external); a `null` scope injects no catalogue. The difference: catalogue is "a menu you pull from on demand", invoked is "the user named it, full body forced into this turn". +### 3.3 Space Prompt Frames + +A fixed Agent Node receives Space-wide user instructions from root or nested Frames whose trimmed, NFC-normalized label matches `prompt` or `prompt: ` case-insensitively and whose `labelSource` is explicitly `user` or `agent`. Auto-generated, missing, or invalid label provenance never activates Prompt Frame behavior; `prompt:` without a non-whitespace suffix is also not recognized. The shared `isPromptFrame()` predicate in [node.ts](../../packages/shared/src/types/canvas/node.ts) is the canonical recognizer. + +[space-prompt.ts](../../apps/server/src/modules/agent/space-prompt.ts) scans the complete Space topology and canonical node records when a fixed Agent Node is first realized. Prompt Frames are ordered by world `y`, world `x`, then stable Frame id. Only direct children participate; children are ordered by Frame-local `y`, Frame-local `x`, then stable node id, with Text and Note nodes left interleaved in that order. + +Text bodies are injected eagerly and in full subject to the total budget. Notes are injected only as lazy `` catalogue references; their bodies remain available through the RFS/read surface and should be read only when Prompt Frame Text, the agent's role, or the current task makes them relevant. Empty Text is omitted, unsupported direct-child types and missing canonical records are omitted with diagnostics, and nested descendants do not participate. Locked nodes remain eligible because locking constrains editing and layout rather than visibility; Huabu currently has no node-level private/hidden permission state to bypass. + +The complete rendered `` fragment, including stable template prose and diagnostics, is capped at 16 KiB UTF-8. Truncation is code-point-safe and emits an explicit diagnostic. A full-Space collection failure rejects first realization instead of silently producing an incomplete instruction set. + +Space Prompt is captured only for fixed Agent Nodes (`agentBindingPolicy: "fixed"`). Ordinary Canvas Chat, selectable Question Nodes, node-less ACP sessions, and the Memory Agent do not receive it. The captured fragment is persisted in the durable WorkloadSpec and reused on later turns, so editing Prompt Frames affects newly realized Agent Nodes but does not mutate existing conversations. Built-in agents place it after their trusted AGENT.md/workspace context and before node-specific initial instructions; external ACP agents place it after Huabu's mandatory bootstrap and before node-specific initial instructions. It remains user-authored context subordinate to system, developer, host-policy, and tool instructions, and it coexists with per-turn selection and neighbourhood context rather than replacing them. + +Space Prompt and the Huabu Skill intentionally use separate channels. Prompt Frames state what agents in this Space should do, while `GET /skill` explains how an external agent operates Huabu and accesses the Space. They share prompt rendering infrastructure but Prompt Frames neither become Skills nor replace the existing authenticated Space-specific `skill.md` override in this version. + --- ## 4. Three "user pointing" signals: selection / anchor / attachment diff --git a/packages/shared/src/types/canvas/index.ts b/packages/shared/src/types/canvas/index.ts index 257b3c05a..9bd95732c 100644 --- a/packages/shared/src/types/canvas/index.ts +++ b/packages/shared/src/types/canvas/index.ts @@ -100,6 +100,8 @@ export { isQuestionNode, getQuestionNodeStatus, normalizeOrigin, + isPromptFrameLabel, + isPromptFrame, } from './node.js'; // Edge types diff --git a/packages/shared/src/types/canvas/node.ts b/packages/shared/src/types/canvas/node.ts index 0816b96aa..90df6d004 100644 --- a/packages/shared/src/types/canvas/node.ts +++ b/packages/shared/src/types/canvas/node.ts @@ -104,6 +104,27 @@ export function normalizeOrigin(raw: unknown): NodeOrigin | undefined { /** Who set the node label โ€” controls whether auto-title may overwrite it */ export type LabelSource = 'auto' | 'user' | 'agent'; +/** + * Whether a Frame label opts into Space-level agent instructions. + * + * Prompt Frames are intentionally label-based in the first version so users + * and agents can create them through existing canvas operations. + */ +export function isPromptFrameLabel(label: unknown): boolean { + return ( + typeof label === 'string' && + /^prompt(?:\s*:\s*\S[\s\S]*)?$/i.test(label.trim().normalize('NFC')) + ); +} + +/** Only explicitly authored labels may activate Prompt Frame semantics. */ +export function isPromptFrame(label: unknown, labelSource: unknown): boolean { + return ( + (labelSource === 'user' || labelSource === 'agent') && + isPromptFrameLabel(label) + ); +} + /** Font family logical names. CSS font stacks are resolved on the UI side. */ export const NODE_FONT_FAMILIES = ['default', 'serif', 'mono', 'hand'] as const; export type NodeFontFamily = (typeof NODE_FONT_FAMILIES)[number]; From b466dbc996c6dbd9415a07271e2fae6004a875b0 Mon Sep 17 00:00:00 2001 From: Yuqing Yang Date: Fri, 4 Sep 2026 16:21:29 +0000 Subject: [PATCH 2/7] feat(agent): add Space Skill Frames and badges Extend authenticated Space guides with live Skill Frame modules and surface Prompt and Skill semantics through shared, zoom-invariant Frame badges. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/modules/agent/agent-thread.service.ts | 4 +- ...st.ts => space-instruction-frames.test.ts} | 78 ++++++++- ...-prompt.ts => space-instruction-frames.ts} | 155 ++++++++++++++---- .../src/modules/remote_fs/rfs.route.test.ts | 114 +++++++++++++ apps/server/src/modules/remote_fs/skill.ts | 20 ++- apps/server/src/prompt/space-skill.md | 14 ++ .../src/components/Nodes/frame/FrameNode.tsx | 111 +++++++------ .../frame/InstructionFrameBadge.test.tsx | 44 +++++ .../Nodes/frame/InstructionFrameBadge.tsx | 38 +++++ apps/web/src/i18n/resources/en/common.json | 4 + apps/web/src/i18n/resources/zh-CN/common.json | 4 + docs/architecture/agent-context.md | 10 +- docs/architecture/agent-reachback.md | 86 +++++----- docs/architecture/canvas-storage.md | 2 +- docs/architecture/canvas-zoom-rendering.md | 4 +- packages/shared/src/types/canvas/index.ts | 5 + .../types/canvas/instruction-frame.test.ts | 36 ++++ packages/shared/src/types/canvas/node.ts | 47 ++++-- 18 files changed, 630 insertions(+), 146 deletions(-) rename apps/server/src/modules/agent/{space-prompt.test.ts => space-instruction-frames.test.ts} (75%) rename apps/server/src/modules/agent/{space-prompt.ts => space-instruction-frames.ts} (57%) create mode 100644 apps/server/src/prompt/space-skill.md create mode 100644 apps/web/src/components/Nodes/frame/InstructionFrameBadge.test.tsx create mode 100644 apps/web/src/components/Nodes/frame/InstructionFrameBadge.tsx create mode 100644 packages/shared/src/types/canvas/instruction-frame.test.ts diff --git a/apps/server/src/modules/agent/agent-thread.service.ts b/apps/server/src/modules/agent/agent-thread.service.ts index 37d2cada7..61158d39c 100644 --- a/apps/server/src/modules/agent/agent-thread.service.ts +++ b/apps/server/src/modules/agent/agent-thread.service.ts @@ -16,14 +16,14 @@ import { runAgent } from './agent.service.js'; import { envelopeHasImage } from './conversation/envelope.js'; import { readWorkspaceMemory } from './memory/index.js'; import { planSkillDispatch } from './skill-model-routing.js'; -import { resolveSpacePrompt } from './space-prompt.js'; +import { resolveSpacePrompt } from './space-instruction-frames.js'; import { acquireAgentTurn, waitForAgentTurnRelease } from './turn-lease.js'; import { loadAgent } from '../../prompt/index.js'; import { canvasAcpNamespace } from '../workspace/paths.js'; import type { HuabuSubmission } from './agenetes/handle.js'; import type { ChatEnvelope } from './conversation/envelope.js'; -import type { RenderedSpacePrompt } from './space-prompt.js'; +import type { RenderedSpacePrompt } from './space-instruction-frames.js'; import type { AgentBinding, AgentMode, diff --git a/apps/server/src/modules/agent/space-prompt.test.ts b/apps/server/src/modules/agent/space-instruction-frames.test.ts similarity index 75% rename from apps/server/src/modules/agent/space-prompt.test.ts rename to apps/server/src/modules/agent/space-instruction-frames.test.ts index 146f69d09..aa1d1a4b8 100644 --- a/apps/server/src/modules/agent/space-prompt.test.ts +++ b/apps/server/src/modules/agent/space-instruction-frames.test.ts @@ -3,7 +3,11 @@ import { describe, expect, it } from 'vitest'; -import { renderSpacePrompt, SPACE_PROMPT_MAX_BYTES } from './space-prompt.js'; +import { + renderSpacePrompt, + renderSpaceSkill, + SPACE_PROMPT_MAX_BYTES, +} from './space-instruction-frames.js'; import type { CanvasFile, NodeContent } from '../storage/index.js'; import type { NodeSnapshot } from '../storage/ports/structured.js'; @@ -126,6 +130,78 @@ describe('renderSpacePrompt', () => { ]); }); + describe('renderSpaceSkill', () => { + it('renders only explicitly authored Skill Frames through the shared compiler', () => { + const topology = canvas([ + { + id: 'frame-skill', + type: 'frame', + position: { x: 0, y: 0 }, + data: {}, + }, + { + id: 'frame-prompt', + type: 'frame', + position: { x: 0, y: 200 }, + data: {}, + }, + { + id: 'text-skill', + type: 'text', + parentId: 'frame-skill', + position: { x: 0, y: 0 }, + data: {}, + }, + { + id: 'text-prompt', + type: 'text', + parentId: 'frame-prompt', + position: { x: 0, y: 0 }, + data: {}, + }, + ]); + const snapshots = records([ + { + nodeId: 'frame-skill', + type: 'frame', + label: 'skill: Research', + labelSource: 'user', + content: '', + }, + { + nodeId: 'frame-prompt', + type: 'frame', + label: 'prompt', + labelSource: 'user', + content: '', + }, + { + nodeId: 'text-skill', + type: 'text', + label: null, + content: 'Use primary sources. ', + }, + { + nodeId: 'text-prompt', + type: 'text', + label: null, + content: 'Prompt-only instruction.', + }, + ]); + + const skill = renderSpaceSkill(topology, snapshots); + const prompt = renderSpacePrompt(topology, snapshots); + + expect(skill?.markdown).toContain('# Space-specific Skills'); + expect(skill?.markdown).toContain('Use primary sources.'); + expect(skill?.markdown).toContain('</space_skill>'); + expect(skill?.markdown.match(/<\/space_skill>/g)).toHaveLength(1); + expect(skill?.markdown).not.toContain('Prompt-only instruction.'); + expect(prompt?.markdown).toContain('Prompt-only instruction.'); + expect(prompt?.markdown).not.toContain('Use primary sources.'); + }); + }); + it('renders direct Text and lazy Note references in stable reading order', () => { const result = renderSpacePrompt( canvas([ diff --git a/apps/server/src/modules/agent/space-prompt.ts b/apps/server/src/modules/agent/space-instruction-frames.ts similarity index 57% rename from apps/server/src/modules/agent/space-prompt.ts rename to apps/server/src/modules/agent/space-instruction-frames.ts index 2b8e04ac8..b42c33364 100644 --- a/apps/server/src/modules/agent/space-prompt.ts +++ b/apps/server/src/modules/agent/space-instruction-frames.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { isPromptFrame } from '@huabu/shared'; +import { classifySpaceInstructionFrame } from '@huabu/shared'; import { nodeRevisionOf } from '@huabu/shared/canvas-engine'; import { buildAgentNodeRef } from './node-ref.js'; @@ -10,9 +10,11 @@ import { buildSpatialBundle } from '../canvas/canvas-spatial.js'; import { space } from '../storage/index.js'; import type { CanvasFile, NodeContent } from '../storage/index.js'; +import type { SpaceInstructionFrameKind } from '@huabu/shared'; import type { CanvasNode } from '@huabu/shared/canvas-engine'; export const SPACE_PROMPT_MAX_BYTES = 16 * 1024; +export const SPACE_SKILL_MAX_BYTES = 16 * 1024; export interface SpacePromptDiagnostics { readonly includedFrameIds: readonly string[]; @@ -28,6 +30,36 @@ export interface RenderedSpacePrompt { readonly diagnostics: SpacePromptDiagnostics; } +export type RenderedSpaceSkill = RenderedSpacePrompt; + +interface InstructionFrameConfig { + readonly kind: SpaceInstructionFrameKind; + readonly template: string; + readonly byteLimit: number; + readonly displayName: 'Space Prompt' | 'Space Skill'; + readonly placeholder: string; +} + +const INSTRUCTION_FRAME_CONFIG: Record< + SpaceInstructionFrameKind, + InstructionFrameConfig +> = { + prompt: { + kind: 'prompt', + template: 'space-prompt.md', + byteLimit: SPACE_PROMPT_MAX_BYTES, + displayName: 'Space Prompt', + placeholder: '{{SPACE_PROMPT_CONTENT}}', + }, + skill: { + kind: 'skill', + template: 'space-skill.md', + byteLimit: SPACE_SKILL_MAX_BYTES, + displayName: 'Space Skill', + placeholder: '{{SPACE_SKILL_CONTENT}}', + }, +}; + interface OrderedNode { readonly raw: CanvasNode; readonly x: number; @@ -54,8 +86,11 @@ function quoteAttribute(value: string): string { .replace(/>/g, '>'); } -function neutralizeSpacePromptTags(value: string): string { - return value.replace(/<\/?space_prompt>/gi, (tag) => `<${tag.slice(1)}`); +function neutralizeInstructionTags(value: string): string { + return value.replace( + /<\/?space_(?:prompt|skill)>/gi, + (tag) => `<${tag.slice(1)}`, + ); } function renderNoteReference(node: CanvasNode, record: NodeContent): string { @@ -89,6 +124,7 @@ function truncateUtf8(value: string, byteLimit: number): string { function renderWithinBudget( content: string, diagnostics: Omit, + config: InstructionFrameConfig, ): RenderedSpacePrompt { const omissionDiagnostics = [ diagnostics.omittedUnsupportedIds.length > 0 @@ -103,45 +139,47 @@ function renderWithinBudget( ] .filter(Boolean) .join('\n'); - const complete = renderPromptFile('space-prompt.md', { + const complete = renderPromptFile(config.template, { content, diagnostics: omissionDiagnostics, }); - if (Buffer.byteLength(complete, 'utf8') <= SPACE_PROMPT_MAX_BYTES) { + if (Buffer.byteLength(complete, 'utf8') <= config.byteLimit) { return { markdown: complete, diagnostics: { ...diagnostics, truncated: false }, }; } - const marker = '\n\n[Space Prompt truncated at the 16 KiB injection limit.]'; + const marker = `\n\n[${config.displayName} truncated at the 16 KiB module limit.]`; const truncatedDiagnostics = [ omissionDiagnostics, - '- Some Prompt Frame content was truncated.', + `- Some ${config.displayName} Frame content was truncated.`, ] .filter(Boolean) .join('\n'); - const shell = renderPromptFile('space-prompt.md', { - content: '{{SPACE_PROMPT_CONTENT}}', + const shell = renderPromptFile(config.template, { + content: config.placeholder, diagnostics: truncatedDiagnostics, }); const available = - SPACE_PROMPT_MAX_BYTES - - Buffer.byteLength(shell.replace('{{SPACE_PROMPT_CONTENT}}', '') + marker); + config.byteLimit - + Buffer.byteLength(shell.replace(config.placeholder, '') + marker); const boundedContent = truncateUtf8(content, Math.max(0, available)); return { markdown: shell.replace( - '{{SPACE_PROMPT_CONTENT}}', + config.placeholder, () => `${boundedContent}${marker}`, ), diagnostics: { ...diagnostics, truncated: true }, }; } -export function renderSpacePrompt( +function renderSpaceInstructionFrames( canvas: CanvasFile, records: ReadonlyMap, + kind: SpaceInstructionFrameKind, ): RenderedSpacePrompt | null { + const config = INSTRUCTION_FRAME_CONFIG[kind]; const bundle = buildSpatialBundle(canvas); const frames = bundle.spatialNodes .filter((node) => { @@ -149,7 +187,8 @@ export function renderSpacePrompt( const record = records.get(node.id)?.record; return ( raw?.type === 'frame' && - isPromptFrame(record?.label, record?.labelSource) + classifySpaceInstructionFrame(record?.label, record?.labelSource) === + kind ); }) .flatMap((node) => { @@ -194,7 +233,7 @@ export function renderSpacePrompt( omittedEmptyTextIds.push(child.raw.id); continue; } - entries.push(neutralizeSpacePromptTags(record.content)); + entries.push(neutralizeInstructionTags(record.content)); } else { entries.push(renderNoteReference(child.raw, record)); } @@ -203,32 +242,88 @@ export function renderSpacePrompt( if (entries.length > 0) { sections.push( - `## ${neutralizeSpacePromptTags(recordLabel(frameRecord)) || 'Prompt'}\n\n${entries.join('\n\n')}`, + `## ${neutralizeInstructionTags(recordLabel(frameRecord)) || config.displayName}\n\n${entries.join('\n\n')}`, ); } } if (sections.length === 0) return null; - return renderWithinBudget(sections.join('\n\n'), { - includedFrameIds: frames.map((frame) => frame.raw.id), - includedNodeIds, - omittedUnsupportedIds, - omittedEmptyTextIds, - omittedMissingIds, - }); + return renderWithinBudget( + sections.join('\n\n'), + { + includedFrameIds: frames.map((frame) => frame.raw.id), + includedNodeIds, + omittedUnsupportedIds, + omittedEmptyTextIds, + omittedMissingIds, + }, + config, + ); +} + +export function renderSpacePrompt( + canvas: CanvasFile, + records: ReadonlyMap, +): RenderedSpacePrompt | null { + return renderSpaceInstructionFrames(canvas, records, 'prompt'); +} + +export function renderSpaceSkill( + canvas: CanvasFile, + records: ReadonlyMap, +): RenderedSpaceSkill | null { + return renderSpaceInstructionFrames(canvas, records, 'skill'); } -export async function resolveSpacePrompt( +async function resolveSpaceInstructionFrames( canvasId: string, + kind: SpaceInstructionFrameKind, ): Promise { const handle = space(canvasId); - const [canvas, records] = await Promise.all([ - handle.read(), - handle.nodes.list(), - ]); + const canvas = await handle.read(); if (!canvas) { - throw new Error(`[space-prompt] Space not found: ${canvasId}`); + throw new Error(`[space-${kind}] Space not found: ${canvasId}`); } - return renderSpacePrompt(canvas as CanvasFile, records); + const rawNodes = (canvas.state.nodes ?? []) as CanvasNode[]; + const frameIds = rawNodes + .filter((node) => node.type === 'frame') + .map((node) => node.id); + if (frameIds.length === 0) return null; + + const frameRecords = await handle.nodes.readMany(frameIds); + const matchingFrameIds = new Set( + frameIds.filter((frameId) => { + const record = frameRecords.get(frameId)?.record; + return ( + classifySpaceInstructionFrame(record?.label, record?.labelSource) === + kind + ); + }), + ); + if (matchingFrameIds.size === 0) return null; + + const childIds = rawNodes + .filter( + (node) => + typeof node.parentId === 'string' && + matchingFrameIds.has(node.parentId) && + (node.type === 'text' || node.type === 'note'), + ) + .map((node) => node.id); + const childRecords = await handle.nodes.readMany(childIds); + const records = new Map([...frameRecords, ...childRecords]); + return renderSpaceInstructionFrames(canvas as CanvasFile, records, kind); +} + +export function resolveSpacePrompt( + canvasId: string, +): Promise { + return resolveSpaceInstructionFrames(canvasId, 'prompt'); +} + +export function resolveSpaceSkill( + canvasId: string, +): Promise { + return resolveSpaceInstructionFrames(canvasId, 'skill'); } diff --git a/apps/server/src/modules/remote_fs/rfs.route.test.ts b/apps/server/src/modules/remote_fs/rfs.route.test.ts index b31a5724b..3bf6ba5d4 100644 --- a/apps/server/src/modules/remote_fs/rfs.route.test.ts +++ b/apps/server/src/modules/remote_fs/rfs.route.test.ts @@ -116,6 +116,55 @@ function seedNote( return `nodes/${toSafeFilename(label, id)}.md`; } +function seedInstructionFrame( + canvasId: string, + kind: 'prompt' | 'skill', + content: string, +): void { + const frameId = `frame-${kind}`; + const textId = `text-${kind}`; + const label = `${kind}: Workspace`; + const store = getCanvasStore(canvasId); + store.write({ + canvasId, + title: null, + version: 1, + state: { + nodes: [ + { + id: frameId, + type: 'frame', + position: { x: 0, y: 0 }, + data: { label }, + }, + { + id: textId, + type: 'text', + parentId: frameId, + position: { x: 0, y: 0 }, + data: {}, + }, + ], + edges: [], + }, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + store.writeNode(frameId, { + nodeId: frameId, + type: 'frame', + label, + labelSource: 'user', + content: '', + }); + store.writeNode(textId, { + nodeId: textId, + type: 'text', + label: null, + content, + }); +} + beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'huabu-rfs-')); setWorkspacePath(tmp); @@ -164,6 +213,23 @@ describe('GET /api/rfs/:canvasId/skill', () => { } }); + it('keeps the authenticated root guide available for a missing Space', async () => { + const app = await buildApp(); + try { + const response = await app.inject({ + method: 'GET', + url: '/rfs/missing/skill', + headers: { authorization: '******' }, + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toMatch(/Accessing this Huabu Space/i); + expect(response.body).not.toContain('# Space-specific Skills'); + } finally { + await app.close(); + } + }); + it('returns only the bundled root guide without authorization', async () => { seedNote('c1', 'node-1', 'Anchor', 'content'); writeFileSync( @@ -191,6 +257,54 @@ describe('GET /api/rfs/:canvasId/skill', () => { } }); + it('appends live Skill Frames to the authenticated Space guide only', async () => { + seedInstructionFrame('c1', 'skill', 'Prefer primary sources.'); + const app = await buildApp(); + try { + const anonymous = await app.inject({ + method: 'GET', + url: '/rfs/c1/skill', + }); + const authenticated = await app.inject({ + method: 'GET', + url: '/rfs/c1/skill', + headers: { authorization: '******' }, + }); + + expect(anonymous.body).toMatch(/Accessing this Huabu Space/); + expect(anonymous.body).not.toContain('Prefer primary sources.'); + expect(authenticated.body).toMatch(/Accessing this Huabu Space/); + expect(authenticated.body).toContain('# Space-specific Skills'); + expect(authenticated.body).toContain('Prefer primary sources.'); + } finally { + await app.close(); + } + }); + + it('appends Skill Frames after a legacy Space guide override', async () => { + seedInstructionFrame('c1', 'skill', 'Use the team glossary.'); + writeFileSync( + join(diskDirOf('c1'), 'skill.md'), + '# Legacy Space Guide', + 'utf8', + ); + const app = await buildApp(); + try { + const response = await app.inject({ + method: 'GET', + url: '/rfs/c1/skill', + headers: { authorization: '******' }, + }); + + expect(response.body).toContain('# Legacy Space Guide'); + expect(response.body).toContain('# Space-specific Skills'); + expect(response.body).toContain('Use the team glossary.'); + expect(response.body).not.toMatch(/Accessing this Huabu Space/i); + } finally { + await app.close(); + } + }); + it('serves only known advanced skills', async () => { const app = await buildApp(); try { diff --git a/apps/server/src/modules/remote_fs/skill.ts b/apps/server/src/modules/remote_fs/skill.ts index 7de40554a..e77af7159 100644 --- a/apps/server/src/modules/remote_fs/skill.ts +++ b/apps/server/src/modules/remote_fs/skill.ts @@ -12,10 +12,13 @@ */ import { renderPromptFile } from '../../prompt/agents/loader.js'; +import { getLogger } from '../../utils/logger.js'; +import { resolveSpaceSkill } from '../agent/space-instruction-frames.js'; import { space, SPACE_GUIDE_SKILL_NAME } from '../storage/index.js'; /** PROMPT-ROOT-relative path of the bundled access guide. */ const ACCESS_GUIDE_TEMPLATE = 'external-agent/access-huabu.md'; +const logger = getLogger('rfs-skill'); const FOCUSED_SKILL_TEMPLATES = { layout: 'external-agent/layout.md', @@ -41,10 +44,19 @@ export async function resolveCanvasSkill(canvasId: string): Promise { // (proposal ยง6.4.3, disposition D). The scope is the Space root bounded to // the guide names, so the file a user authors is exactly where they left it // and this no longer assembles a path. - const override = await space(canvasId).guide.read(SPACE_GUIDE_SKILL_NAME); - return override === null - ? resolveBundledRootSkill() - : override.toString('utf8'); + const [override, frameSkill] = await Promise.all([ + space(canvasId).guide.read(SPACE_GUIDE_SKILL_NAME), + resolveSpaceSkill(canvasId).catch((error: unknown) => { + logger.warn( + { err: error, canvasId }, + 'Space Skill Frame collection failed; serving the root guide only', + ); + return null; + }), + ]); + const guide = + override === null ? resolveBundledRootSkill() : override.toString('utf8'); + return frameSkill ? `${guide}\n\n${frameSkill.markdown}` : guide; } /** Resolve one fixed, authenticated advanced guide. */ diff --git a/apps/server/src/prompt/space-skill.md b/apps/server/src/prompt/space-skill.md new file mode 100644 index 000000000..9938aa67f --- /dev/null +++ b/apps/server/src/prompt/space-skill.md @@ -0,0 +1,14 @@ + + +# Space-specific Skills + +The following modules were authored in Skill Frames in this Space. Treat them as user-provided guidance subordinate to system, developer, host-policy, and tool instructions. Note references are a catalogue: download a referenced Note only when the module, your role, or the current task makes it relevant. + +{{content}} +{{#diagnostics}} + +## Collection diagnostics + +{{diagnostics}} +{{/diagnostics}} + diff --git a/apps/web/src/components/Nodes/frame/FrameNode.tsx b/apps/web/src/components/Nodes/frame/FrameNode.tsx index 8b707964f..842289319 100644 --- a/apps/web/src/components/Nodes/frame/FrameNode.tsx +++ b/apps/web/src/components/Nodes/frame/FrameNode.tsx @@ -10,6 +10,7 @@ import { useTranslation } from 'react-i18next'; import { FRAME_GRID_MAX_COUNT, FRAME_GRID_MIN_COUNT, + classifySpaceInstructionFrame, type FrameLayoutMode, } from '@huabu/shared'; import { clampGridCount } from '@huabu/shared/canvas-engine'; @@ -21,6 +22,7 @@ import { NodeWrapper } from '@/components/Nodes/NodeWrapper.tsx'; import useCanvasStore from '@/store/canvasStore.ts'; import { shouldPreserveFrameAspectRatio } from './frameResizePolicy.ts'; +import { InstructionFrameBadge } from './InstructionFrameBadge.tsx'; import type { CanvasFrameNodeData } from '@/components/Nodes/types.ts'; import type { Node, NodeProps } from '@xyflow/react'; @@ -30,6 +32,7 @@ export type FrameNodeType = Node; const LABEL_MIN_VERTICAL_GAP = 22; const LABEL_COLLISION_HYSTERESIS = 4; const LABEL_MIN_SCREEN_WIDTH = 48; +const INSTRUCTION_LABEL_MIN_SCREEN_WIDTH = 112; function shouldShowNestedLabel( ancestorGap: number | null, @@ -414,6 +417,10 @@ export const FrameNode = memo( const trimmed = raw.trim(); return trimmed.length > 0 ? trimmed : t('layers.filterLabels.frame'); }, [data.label, t]); + const instructionFrameKind = classifySpaceInstructionFrame( + data.label, + data.labelSource, + ); const [isEditingLabel, setIsEditingLabel] = useState(false); const [draftLabel, setDraftLabel] = useState(label); @@ -496,53 +503,58 @@ export const FrameNode = memo( // Rendered in the zoom-invariant overlay so the label keeps a fixed screen size const labelOverlay = ( -
- - {draftLabel || ' '} - - - { - if (!isEditingLabel) return; - setDraftLabel(e.target.value); - }} - onClick={() => { - if (isEditingLabel) return; - setIsEditingLabel(true); - }} - onBlur={() => { - if (!isEditingLabel) return; - commitLabel(); - }} - onKeyDown={(e) => { - if (!isEditingLabel) return; - e.stopPropagation(); - if (e.key === 'Enter') { - e.preventDefault(); +
+ {instructionFrameKind ? ( + + ) : null} +
+ + {draftLabel || ' '} + + + { + if (!isEditingLabel) return; + setDraftLabel(e.target.value); + }} + onClick={() => { + if (isEditingLabel) return; + setIsEditingLabel(true); + }} + onBlur={() => { + if (!isEditingLabel) return; commitLabel(); - } - if (e.key === 'Escape') { - e.preventDefault(); - setDraftLabel(label); - setIsEditingLabel(false); - } - }} - /> + }} + onKeyDown={(e) => { + if (!isEditingLabel) return; + e.stopPropagation(); + if (e.key === 'Enter') { + e.preventDefault(); + commitLabel(); + } + if (e.key === 'Escape') { + e.preventDefault(); + setDraftLabel(label); + setIsEditingLabel(false); + } + }} + /> +
); @@ -557,7 +569,12 @@ export const FrameNode = memo( overlayOffsetY={-24} overlayVisible={labelSemanticallyVisible} overlayInteractionPriority={isEditingLabel ? 3 : selected ? 2 : 0} - overlayMaxWidth={Math.max(LABEL_MIN_SCREEN_WIDTH, nodeWidth * zoom)} + overlayMaxWidth={Math.max( + instructionFrameKind + ? INSTRUCTION_LABEL_MIN_SCREEN_WIDTH + : LABEL_MIN_SCREEN_WIDTH, + nodeWidth * zoom, + )} keepAspectRatio={shouldPreserveFrameAspectRatio({ sizing: data.sizing, hasMediaChild, diff --git a/apps/web/src/components/Nodes/frame/InstructionFrameBadge.test.tsx b/apps/web/src/components/Nodes/frame/InstructionFrameBadge.test.tsx new file mode 100644 index 000000000..8fee91cf3 --- /dev/null +++ b/apps/web/src/components/Nodes/frame/InstructionFrameBadge.test.tsx @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { InstructionFrameBadge } from './InstructionFrameBadge.tsx'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +describe('InstructionFrameBadge', () => { + let container: HTMLDivElement | undefined; + + afterEach(() => { + container?.remove(); + container = undefined; + }); + + it.each([ + ['prompt', 'node.promptFrameBadge', 'bg-info'], + ['skill', 'node.skillFrameBadge', 'bg-success'], + ] as const)('renders the %s semantic pill', (kind, label, tone) => { + container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + act(() => root.render()); + + const badge = container.querySelector('span'); + expect(badge?.textContent).toBe(label); + expect(badge?.classList.contains(tone)).toBe(true); + expect(badge?.classList.contains('text-fg-inverse')).toBe(true); + expect(badge?.classList.contains('rounded-full')).toBe(true); + expect(badge?.querySelector('svg')?.getAttribute('aria-hidden')).toBe( + 'true', + ); + + act(() => root.unmount()); + }); +}); diff --git a/apps/web/src/components/Nodes/frame/InstructionFrameBadge.tsx b/apps/web/src/components/Nodes/frame/InstructionFrameBadge.tsx new file mode 100644 index 000000000..938e9562d --- /dev/null +++ b/apps/web/src/components/Nodes/frame/InstructionFrameBadge.tsx @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import clsx from 'clsx'; +import { BookOpen, MessageSquareQuote } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +import type { SpaceInstructionFrameKind } from '@huabu/shared'; + +export function InstructionFrameBadge({ + kind, +}: { + kind: SpaceInstructionFrameKind; +}) { + const { t } = useTranslation(); + const isPrompt = kind === 'prompt'; + + return ( + + {isPrompt ? ( + + ); +} diff --git a/apps/web/src/i18n/resources/en/common.json b/apps/web/src/i18n/resources/en/common.json index 5da0d71c2..2bbfc98d4 100644 --- a/apps/web/src/i18n/resources/en/common.json +++ b/apps/web/src/i18n/resources/en/common.json @@ -475,6 +475,10 @@ "rowsRange": "Rows (1โ€“{{max}})", "unframe": "Unframe", "editFrameName": "Edit frame name", + "promptFrameBadge": "Prompt", + "promptFrameBadgeDescription": "This Frame provides instructions to Agent Nodes", + "skillFrameBadge": "Skill", + "skillFrameBadgeDescription": "This Frame extends the authenticated Space skill", "watchLiveConversation": "Watch live conversation", "viewConversation": "View conversation", "openConversation": "Open conversation", diff --git a/apps/web/src/i18n/resources/zh-CN/common.json b/apps/web/src/i18n/resources/zh-CN/common.json index ebd51473c..9ca4e2833 100644 --- a/apps/web/src/i18n/resources/zh-CN/common.json +++ b/apps/web/src/i18n/resources/zh-CN/common.json @@ -475,6 +475,10 @@ "rowsRange": "่กŒๆ•ฐ๏ผˆ1โ€“{{max}}๏ผ‰", "unframe": "ๅ–ๆถˆๆก†ๆžถ", "editFrameName": "็ผ–่พ‘ๆก†ๆžถๅ็งฐ", + "promptFrameBadge": "ๆ็คบ่ฏ", + "promptFrameBadgeDescription": "ๆญคๆก†ๆžถไธบ Agent ่Š‚็‚นๆไพ›ๆŒ‡ไปค", + "skillFrameBadge": "ๆŠ€่ƒฝ", + "skillFrameBadgeDescription": "ๆญคๆก†ๆžถๆ‰ฉๅฑ•ๅทฒ่ฎค่ฏ็š„็ฉบ้—ดๆŠ€่ƒฝ", "watchLiveConversation": "ๆŸฅ็œ‹ๅฎžๆ—ถๅฏน่ฏ", "viewConversation": "ๆŸฅ็œ‹ๅฏน่ฏ", "openConversation": "ๆ‰“ๅผ€ๅฏน่ฏ", diff --git a/docs/architecture/agent-context.md b/docs/architecture/agent-context.md index b76646051..fc332504d 100644 --- a/docs/architecture/agent-context.md +++ b/docs/architecture/agent-context.md @@ -62,11 +62,11 @@ Skills are **not tools**; they reach the prompt via two complementary paths: The catalogue is filtered by the agent's frontmatter `skillScope` (ask/operate/external); a `null` scope injects no catalogue. The difference: catalogue is "a menu you pull from on demand", invoked is "the user named it, full body forced into this turn". -### 3.3 Space Prompt Frames +### 3.3 Space instruction Frames -A fixed Agent Node receives Space-wide user instructions from root or nested Frames whose trimmed, NFC-normalized label matches `prompt` or `prompt: ` case-insensitively and whose `labelSource` is explicitly `user` or `agent`. Auto-generated, missing, or invalid label provenance never activates Prompt Frame behavior; `prompt:` without a non-whitespace suffix is also not recognized. The shared `isPromptFrame()` predicate in [node.ts](../../packages/shared/src/types/canvas/node.ts) is the canonical recognizer. +Space instruction Frames use two channels: Prompt Frames inject instructions into fixed Agent Nodes, while Skill Frames extend the authenticated guide returned by `GET /skill`. Their trimmed, NFC-normalized labels match `prompt`, `prompt: `, `skill`, or `skill: ` case-insensitively and require `labelSource` to be explicitly `user` or `agent`. Auto-generated, missing, or invalid label provenance never activates instruction Frame behavior; a colon without a non-whitespace suffix is also not recognized. The shared `classifySpaceInstructionFrame()` predicate in [node.ts](../../packages/shared/src/types/canvas/node.ts) is the canonical recognizer used by the server and the Frame badges. -[space-prompt.ts](../../apps/server/src/modules/agent/space-prompt.ts) scans the complete Space topology and canonical node records when a fixed Agent Node is first realized. Prompt Frames are ordered by world `y`, world `x`, then stable Frame id. Only direct children participate; children are ordered by Frame-local `y`, Frame-local `x`, then stable node id, with Text and Note nodes left interleaved in that order. +[space-instruction-frames.ts](../../apps/server/src/modules/agent/space-instruction-frames.ts) scans the complete Space topology and canonical node records. Instruction Frames are ordered by world `y`, world `x`, then stable Frame id. Only direct children participate; children are ordered by Frame-local `y`, Frame-local `x`, then stable node id, with Text and Note nodes left interleaved in that order. Text bodies are injected eagerly and in full subject to the total budget. Notes are injected only as lazy `` catalogue references; their bodies remain available through the RFS/read surface and should be read only when Prompt Frame Text, the agent's role, or the current task makes them relevant. Empty Text is omitted, unsupported direct-child types and missing canonical records are omitted with diagnostics, and nested descendants do not participate. Locked nodes remain eligible because locking constrains editing and layout rather than visibility; Huabu currently has no node-level private/hidden permission state to bypass. @@ -74,7 +74,9 @@ The complete rendered `` fragment, including stable template prose Space Prompt is captured only for fixed Agent Nodes (`agentBindingPolicy: "fixed"`). Ordinary Canvas Chat, selectable Question Nodes, node-less ACP sessions, and the Memory Agent do not receive it. The captured fragment is persisted in the durable WorkloadSpec and reused on later turns, so editing Prompt Frames affects newly realized Agent Nodes but does not mutate existing conversations. Built-in agents place it after their trusted AGENT.md/workspace context and before node-specific initial instructions; external ACP agents place it after Huabu's mandatory bootstrap and before node-specific initial instructions. It remains user-authored context subordinate to system, developer, host-policy, and tool instructions, and it coexists with per-turn selection and neighbourhood context rather than replacing them. -Space Prompt and the Huabu Skill intentionally use separate channels. Prompt Frames state what agents in this Space should do, while `GET /skill` explains how an external agent operates Huabu and accesses the Space. They share prompt rendering infrastructure but Prompt Frames neither become Skills nor replace the existing authenticated Space-specific `skill.md` override in this version. +Space Prompt and the Huabu Skill intentionally use separate delivery channels while sharing discovery, ordering, Text/Note rendering, diagnostics, and a 16 KiB per-channel module budget. Prompt Frames state what fixed Agent Nodes in this Space should do and are captured once. Skill Frames are resolved live on every authenticated `GET /skill`, appended after the current root guide, and never enter Agent Node preambles. Anonymous `GET /skill` continues to return only the bundled public guide. A legacy Space-specific `skill.md` remains the root-guide override; when present, live Skill Frames append after that override so existing behavior and new modular customization coexist. Skill Frame collection is additive: if topology or node records cannot be read, the failure is logged and the root guide is still served. + +Recognized Prompt and Skill Frames carry a zoom-invariant solid badge beside the editable Frame label. Prompt uses the semantic info tone and Skill uses the semantic success tone; the badge is non-interactive, participates in the existing label width cap and collision visibility, and uses the same shared classifier as delivery. --- diff --git a/docs/architecture/agent-reachback.md b/docs/architecture/agent-reachback.md index f64b7c5df..89b084648 100644 --- a/docs/architecture/agent-reachback.md +++ b/docs/architecture/agent-reachback.md @@ -8,7 +8,7 @@ The surface separates byte transfer from semantic canvas work: ```text External agent - โ”œโ”€ skill โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€> bundled root guide + authenticated advanced guides + โ”œโ”€ skill โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€> bundled/override root guide + live Space Skill Frames + advanced guides โ”œโ”€ download / upload โ”€โ”€> canvas file projection โ”œโ”€ query โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€> canonical SpaceQuery dispatcher โ”œโ”€ query SNAPSHOT_NODES โ”€โ”€> shared snapshot renderer โ”€โ”€> PNG artifact @@ -22,23 +22,23 @@ The shipped design record is [`agent-reachback-rfs.md`](../proposals/agent-reach All endpoints are mounted under `/api/rfs/:canvasId`; `HUABU_RFS_URL` already contains that canvas-scoped base. -| Endpoint | Responsibility | -| ---------------------------------- | ------------------------------------------------------------------------------------------- | -| `GET /skill` | Return the public bundled root guide, or an authenticated canvas-specific root override. | -| `GET /skill/:skillId` | Return an authenticated advanced guide: `layout`, `tasks`, or `agents`. | -| `GET /download/` | Stream a known node, artifact, or staged-upload file. | -| `POST /upload/` | Stage bytes in the canvas `.upload/` directory without creating a node. | -| `DELETE /upload/` | Remove one exact staged upload. | -| `POST /agent` | Create a visible Agent Node and optionally start its first turn. | -| `POST /agent/:threadId/prompt` | Submit a turn to an existing Agent conversation over SSE. | -| `GET /agent/profiles` | Return available Agent Profile IDs and aliases, including the default `huabu` Profile. | -| `POST /task/create` | Create a durable Task and its static Task Note. | -| `POST /task/:taskId/run/create` | Create a Run, its visible root Agent Node, and start the first turn. | -| `GET /capabilities` | Report the direct-operation protocol, limits, semantics, and supported operation types. | -| `GET /capabilities/queries/:type` | Return one query's generated JSON Schema, constraints, result description, and examples. | -| `GET /capabilities/commands/:type` | Return one command's generated JSON Schema, constraints, result description, and examples. | -| `POST /query` | Validate and execute one bounded `SpaceQuery`, returning a query-discriminated JSON result. | -| `POST /execute` | Validate and execute an ordered batch of agent-allowed `CanvasCommand` variants. | +| Endpoint | Responsibility | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| `GET /skill` | Return the public bundled root guide; authenticated requests resolve the current root guide and append live Skill Frames. | +| `GET /skill/:skillId` | Return an authenticated advanced guide: `layout`, `tasks`, or `agents`. | +| `GET /download/` | Stream a known node, artifact, or staged-upload file. | +| `POST /upload/` | Stage bytes in the canvas `.upload/` directory without creating a node. | +| `DELETE /upload/` | Remove one exact staged upload. | +| `POST /agent` | Create a visible Agent Node and optionally start its first turn. | +| `POST /agent/:threadId/prompt` | Submit a turn to an existing Agent conversation over SSE. | +| `GET /agent/profiles` | Return available Agent Profile IDs and aliases, including the default `huabu` Profile. | +| `POST /task/create` | Create a durable Task and its static Task Note. | +| `POST /task/:taskId/run/create` | Create a Run, its visible root Agent Node, and start the first turn. | +| `GET /capabilities` | Report the direct-operation protocol, limits, semantics, and supported operation types. | +| `GET /capabilities/queries/:type` | Return one query's generated JSON Schema, constraints, result description, and examples. | +| `GET /capabilities/commands/:type` | Return one command's generated JSON Schema, constraints, result description, and examples. | +| `POST /query` | Validate and execute one bounded `SpaceQuery`, returning a query-discriminated JSON result. | +| `POST /execute` | Validate and execute an ordered batch of agent-allowed `CanvasCommand` variants. | There is no directory-listing endpoint. External agents receive exact node paths in selected-node context or ask the internal agent to discover relevant files. @@ -68,7 +68,7 @@ The query response returns each artifact's bare `src`, public `downloadPath`, PN Every operational RFS request and every focused `GET /skill/:skillId` request requires `Authorization: Bearer `. The global server auth hook compares the bearer value with the active Agentlet connection token, and the canvas ID embedded in `HUABU_RFS_URL` scopes route resolution to one Space. -The only anonymous exception is `GET /skill` with no Authorization header. It returns the bundled root guide without resolving the Canvas, revealing whether it exists, or reading its `skill.md` override. An authenticated root request may resolve that override; a supplied invalid credential returns `401` rather than falling back to public documentation. +The only anonymous exception is `GET /skill` with no Authorization header. It returns the bundled root guide without resolving the Canvas, revealing whether it exists, reading its `skill.md` override, or collecting Skill Frames. An authenticated root request resolves the override when present and appends live `skill` / `skill: ` Frame modules in deterministic reading order; if the additive Frame collection fails, Huabu logs the failure and still serves the root guide. A supplied invalid credential returns `401` rather than falling back to public documentation. The shipped token grants access to the complete RFS surface, including direct reads and writes, and `/capabilities` reports both permissions as enabled. The canvas ID scopes route resolution but is not an independent credential or security boundary. @@ -92,7 +92,7 @@ Parent lineage is best effort. The route resolves `parentThreadId` or `X-Huabu-H ## External-agent bootstrap -Huabu injects `HUABU_RFS_URL` and `AGENTLET_TOKEN` into the external agent environment. Every external-agent Deployment persists the bootstrap as its initial preamble, including Deployments first created by mode, model, or configuration control requests; startup repair backfills older undelivered records that omitted it. The preamble owns the authentication contract and curl header setup because every external Agent needs them before loading any Skill. The complete basic guide is loaded without credentials from `GET /skill`; advanced layout, Task, recursive-Agent, and Interactive View procedures are loaded on demand from authenticated `GET /skill/layout`, `/skill/tasks`, `/skill/agents`, and `/skill/interactive-views`. +Huabu injects `HUABU_RFS_URL` and `AGENTLET_TOKEN` into the external agent environment. Every external-agent Deployment persists the bootstrap as its initial preamble, including Deployments first created by mode, model, or configuration control requests; startup repair backfills older undelivered records that omitted it. The preamble owns the authentication contract and curl header setup because every external Agent needs them before loading any Skill. The public basic guide is loaded without credentials from `GET /skill`; repeating that request with authentication adds the current root override and live Space Skill Frames. Advanced layout, Task, recursive-Agent, and Interactive View procedures are loaded on demand from authenticated `GET /skill/layout`, `/skill/tasks`, `/skill/agents`, and `/skill/interactive-views`. Skills explain when and how to compose workflows, but they do not duplicate the wire protocol. `GET /capabilities` and its per-operation endpoints remain the canonical, schema-derived source for current query and command fields, limits, and semantics. @@ -118,26 +118,26 @@ An external agent runs as an untrusted third-party CLI. It must receive its Huab ## Code entry points -| File/dir | Responsibility | -| ---------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | -| [`apps/server/src/modules/remote_fs/rfs.route.ts`](../../apps/server/src/modules/remote_fs/rfs.route.ts) | Canvas-scoped file, capability, direct-operation, and ask-agent routes. | -| [`apps/server/src/modules/remote_fs/space-capabilities.ts`](../../apps/server/src/modules/remote_fs/space-capabilities.ts) | Compact capability handshake and schema-derived per-operation descriptions. | -| [`apps/server/src/modules/remote_fs/space-execute.ts`](../../apps/server/src/modules/remote_fs/space-execute.ts) | Agent-friendly projection over canonical command preparation and execution. | -| [`apps/server/src/modules/canvas/space-query.ts`](../../apps/server/src/modules/canvas/space-query.ts) | Canonical query dispatcher over spatial and search services. | -| [`apps/server/src/modules/canvas/agent-command-preparation.ts`](../../apps/server/src/modules/canvas/agent-command-preparation.ts) | Shared server-owned authorship and built-in read-set annotation. | -| [`apps/server/src/modules/canvas/snapshot-nodes.ts`](../../apps/server/src/modules/canvas/snapshot-nodes.ts) | Shared node-to-artifact snapshot query implementation. | -| [`apps/server/src/modules/remote_fs/node-meta.ts`](../../apps/server/src/modules/remote_fs/node-meta.ts) | Safe path projection and node metadata headers. | -| [`apps/server/src/modules/remote_fs/skill.ts`](../../apps/server/src/modules/remote_fs/skill.ts) | Resolve the public bundled root, authenticated canvas override, and advanced guides. | -| [`apps/server/src/prompt/external-agent/access-huabu.md`](../../apps/server/src/prompt/external-agent/access-huabu.md) | Agent-facing RFS procedure served by `GET /skill`. | -| [`apps/server/src/prompt/external-agent/layout.md`](../../apps/server/src/prompt/external-agent/layout.md) | Advanced RFS adapter over the shared Space layout recipes. | -| [`apps/server/src/prompt/external-agent/tasks.md`](../../apps/server/src/prompt/external-agent/tasks.md) | Durable Task and Run workflow served by `GET /skill/tasks`. | -| [`apps/server/src/prompt/external-agent/agents.md`](../../apps/server/src/prompt/external-agent/agents.md) | Delegated and recursive Agent workflow served by `GET /skill/agents`. | -| [`apps/server/src/prompt/external-agent/interactive-views.md`](../../apps/server/src/prompt/external-agent/interactive-views.md) | Interactive View creation and bridge workflow served by `GET /skill/interactive-views`. | -| [`apps/server/src/modules/interactive-view/`](../../apps/server/src/modules/interactive-view/) | View resource validation, persistence, binding snapshots, and Agent action dispatch. | -| [`apps/server/src/prompt/external-agent/system-preamble.ts`](../../apps/server/src/prompt/external-agent/system-preamble.ts) | Render the canonical external-agent bootstrap preamble. | -| [`apps/server/src/modules/agent/acp/reachback-env.ts`](../../apps/server/src/modules/agent/acp/reachback-env.ts) | Inject the canvas-scoped RFS environment into external sessions. | -| [`apps/server/src/modules/storage/migrate-agenetes-threads.ts`](../../apps/server/src/modules/storage/migrate-agenetes-threads.ts) | Repair persisted undelivered external Deployments that omitted the bootstrap preamble. | -| [`external/agenetes/packages/agentlet-host/src/daemon-supervisor.ts`](../../external/agenetes/packages/agentlet-host/src/daemon-supervisor.ts) | Fork/supervise the daemon; `filterHostNamespacedEnv` strips the host namespace at the transport boundary. | -| [`packages/shared/src/types/api/rfs.ts`](../../packages/shared/src/types/api/rfs.ts) | Shared file-plane RFS wire schemas, headers, and constants. | -| [`packages/shared/src/types/api/space-operations.ts`](../../packages/shared/src/types/api/space-operations.ts) | Canonical direct-operation request, response, capability, and limit contracts. | -| [`external/agentlet/spec/agent-reachback.md`](../../external/agentlet/spec/agent-reachback.md) | Host-agnostic Agentlet reachback transport contract. | +| File/dir | Responsibility | +| ---------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| [`apps/server/src/modules/remote_fs/rfs.route.ts`](../../apps/server/src/modules/remote_fs/rfs.route.ts) | Canvas-scoped file, capability, direct-operation, and ask-agent routes. | +| [`apps/server/src/modules/remote_fs/space-capabilities.ts`](../../apps/server/src/modules/remote_fs/space-capabilities.ts) | Compact capability handshake and schema-derived per-operation descriptions. | +| [`apps/server/src/modules/remote_fs/space-execute.ts`](../../apps/server/src/modules/remote_fs/space-execute.ts) | Agent-friendly projection over canonical command preparation and execution. | +| [`apps/server/src/modules/canvas/space-query.ts`](../../apps/server/src/modules/canvas/space-query.ts) | Canonical query dispatcher over spatial and search services. | +| [`apps/server/src/modules/canvas/agent-command-preparation.ts`](../../apps/server/src/modules/canvas/agent-command-preparation.ts) | Shared server-owned authorship and built-in read-set annotation. | +| [`apps/server/src/modules/canvas/snapshot-nodes.ts`](../../apps/server/src/modules/canvas/snapshot-nodes.ts) | Shared node-to-artifact snapshot query implementation. | +| [`apps/server/src/modules/remote_fs/node-meta.ts`](../../apps/server/src/modules/remote_fs/node-meta.ts) | Safe path projection and node metadata headers. | +| [`apps/server/src/modules/remote_fs/skill.ts`](../../apps/server/src/modules/remote_fs/skill.ts) | Resolve the public bundled root, authenticated canvas override plus live Skill Frames, and advanced guides. | +| [`apps/server/src/prompt/external-agent/access-huabu.md`](../../apps/server/src/prompt/external-agent/access-huabu.md) | Agent-facing RFS procedure served by `GET /skill`. | +| [`apps/server/src/prompt/external-agent/layout.md`](../../apps/server/src/prompt/external-agent/layout.md) | Advanced RFS adapter over the shared Space layout recipes. | +| [`apps/server/src/prompt/external-agent/tasks.md`](../../apps/server/src/prompt/external-agent/tasks.md) | Durable Task and Run workflow served by `GET /skill/tasks`. | +| [`apps/server/src/prompt/external-agent/agents.md`](../../apps/server/src/prompt/external-agent/agents.md) | Delegated and recursive Agent workflow served by `GET /skill/agents`. | +| [`apps/server/src/prompt/external-agent/interactive-views.md`](../../apps/server/src/prompt/external-agent/interactive-views.md) | Interactive View creation and bridge workflow served by `GET /skill/interactive-views`. | +| [`apps/server/src/modules/interactive-view/`](../../apps/server/src/modules/interactive-view/) | View resource validation, persistence, binding snapshots, and Agent action dispatch. | +| [`apps/server/src/prompt/external-agent/system-preamble.ts`](../../apps/server/src/prompt/external-agent/system-preamble.ts) | Render the canonical external-agent bootstrap preamble. | +| [`apps/server/src/modules/agent/acp/reachback-env.ts`](../../apps/server/src/modules/agent/acp/reachback-env.ts) | Inject the canvas-scoped RFS environment into external sessions. | +| [`apps/server/src/modules/storage/migrate-agenetes-threads.ts`](../../apps/server/src/modules/storage/migrate-agenetes-threads.ts) | Repair persisted undelivered external Deployments that omitted the bootstrap preamble. | +| [`external/agenetes/packages/agentlet-host/src/daemon-supervisor.ts`](../../external/agenetes/packages/agentlet-host/src/daemon-supervisor.ts) | Fork/supervise the daemon; `filterHostNamespacedEnv` strips the host namespace at the transport boundary. | +| [`packages/shared/src/types/api/rfs.ts`](../../packages/shared/src/types/api/rfs.ts) | Shared file-plane RFS wire schemas, headers, and constants. | +| [`packages/shared/src/types/api/space-operations.ts`](../../packages/shared/src/types/api/space-operations.ts) | Canonical direct-operation request, response, capability, and limit contracts. | +| [`external/agentlet/spec/agent-reachback.md`](../../external/agentlet/spec/agent-reachback.md) | Host-agnostic Agentlet reachback transport contract. | diff --git a/docs/architecture/canvas-storage.md b/docs/architecture/canvas-storage.md index 2b5afc624..e6702e041 100644 --- a/docs/architecture/canvas-storage.md +++ b/docs/architecture/canvas-storage.md @@ -24,7 +24,7 @@ Runtime Home-folder activation reserves the namespace switch before preparing an skills//SKILL.md # user / memory-agent authored skills / # dir name = safe(title) space.json # { canvasId, title, version, state:{nodes,edges,...}, createdAt, updatedAt } - skill.md # optional per-Space RFS access guide (blob area `guide`) + skill.md # optional legacy per-Space RFS root-guide override (blob area `guide`) nodes/ .md # frontmatter: id/type/label/src/... + content(markdown body) .artifacts/ # Disk BlobStore mapping for this Space diff --git a/docs/architecture/canvas-zoom-rendering.md b/docs/architecture/canvas-zoom-rendering.md index ab86fcd29..e204ae37c 100644 --- a/docs/architecture/canvas-zoom-rendering.md +++ b/docs/architecture/canvas-zoom-rendering.md @@ -84,7 +84,7 @@ Because the geometry is exact every frame, the overlay simply re-renders at the Frame geometry always remains full canvas-space content. A frame does not use `SemanticPlaceholder` because its border and containment boundary are structural information even when zoomed out. -The editable frame name is different: [`FrameNode`](../../apps/web/src/components/Nodes/frame/FrameNode.tsx) sends it through the screen-space overlay owned by [`NodeWrapper`](../../apps/web/src/components/Nodes/NodeWrapper.tsx), positioned 24 px above the transformed frame top. Its `text-xs` typography therefore remains readable instead of shrinking with the frame. +The editable frame name is different: [`FrameNode`](../../apps/web/src/components/Nodes/frame/FrameNode.tsx) sends it through the screen-space overlay owned by [`NodeWrapper`](../../apps/web/src/components/Nodes/NodeWrapper.tsx), positioned 24 px above the transformed frame top. Its `text-xs` typography therefore remains readable instead of shrinking with the frame. Frames recognized as Space instruction channels place a non-interactive solid pill in the same overlay: Prompt uses the semantic info tone with a quote icon, while Skill uses the semantic success tone with a book icon. The shared server/Web classifier requires an explicitly user- or agent-authored `prompt` / `prompt: ...` / `skill` / `skill: ...` label, so the visual signal cannot drift from delivery behavior. Fixed screen-space labels can overlap when nested frame top edges converge during zoom-out. `FrameNode` compares the vertical screen-space gap to the nearest frame ancestor and hides the nested label below 22 px, with a 4 px hysteresis buffer around subsequent hide/reveal transitions. @@ -102,7 +102,7 @@ When frame labels collide, the higher-priority label wins: The first three interaction states force the affected label to remain visible and use matching overlay layers in descending order. With no interaction, the outer frame wins because zoomed-out views prioritize structural context over nested detail; the inner label returns after sufficient screen-space separation. -Frame label width is capped to the transformed frame width with a 48 px usability floor. An overflowing name truncates with an ellipsis so the clipped remainder is visible as such; the ellipsis disappears while the label is being edited, where the input scrolls instead. +Frame label width is capped to the transformed frame width with a 48 px usability floor, raised to 112 px when an instruction badge is present so the badge cannot collapse the editable name hit target. The badge remains visible while the remaining name width shrinks; an overflowing name truncates with an ellipsis so the clipped remainder is visible as such. The ellipsis disappears while the label is being edited, where the input scrolls instead. `FrameNode` owns the hierarchy and collision policy because it is frame-specific. `NodeWrapper` remains generic: it converts node coordinates to screen coordinates, applies owner-provided semantic visibility and width, handles interaction reveal, and performs opacity/FLIP transitions. diff --git a/packages/shared/src/types/canvas/index.ts b/packages/shared/src/types/canvas/index.ts index 9bd95732c..505bebf5e 100644 --- a/packages/shared/src/types/canvas/index.ts +++ b/packages/shared/src/types/canvas/index.ts @@ -71,6 +71,7 @@ export type { QuestionNodeData, QuestionNodeStatus, LabelSource, + SpaceInstructionFrameKind, NodeData, } from './node.js'; @@ -100,8 +101,12 @@ export { isQuestionNode, getQuestionNodeStatus, normalizeOrigin, + classifySpaceInstructionFrameLabel, + classifySpaceInstructionFrame, isPromptFrameLabel, isPromptFrame, + isSkillFrameLabel, + isSkillFrame, } from './node.js'; // Edge types diff --git a/packages/shared/src/types/canvas/instruction-frame.test.ts b/packages/shared/src/types/canvas/instruction-frame.test.ts new file mode 100644 index 000000000..54f43839e --- /dev/null +++ b/packages/shared/src/types/canvas/instruction-frame.test.ts @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { describe, expect, it } from 'vitest'; + +import { + classifySpaceInstructionFrame, + classifySpaceInstructionFrameLabel, +} from './node.js'; + +describe('Space instruction Frame labels', () => { + it.each([ + ['prompt', 'prompt'], + [' Prompt: Review ', 'prompt'], + ['SKILL', 'skill'], + ['skill: Research', 'skill'], + ] as const)('classifies %s as %s', (label, expected) => { + expect(classifySpaceInstructionFrameLabel(label)).toBe(expected); + }); + + it.each(['prompt:', 'skill: ', 'prompt module', '', null])( + 'rejects invalid label %s', + (label) => { + expect(classifySpaceInstructionFrameLabel(label)).toBeNull(); + }, + ); + + it('requires explicit user or agent label provenance', () => { + expect(classifySpaceInstructionFrame('skill', 'user')).toBe('skill'); + expect(classifySpaceInstructionFrame('prompt: Task', 'agent')).toBe( + 'prompt', + ); + expect(classifySpaceInstructionFrame('skill', 'auto')).toBeNull(); + expect(classifySpaceInstructionFrame('prompt', undefined)).toBeNull(); + }); +}); diff --git a/packages/shared/src/types/canvas/node.ts b/packages/shared/src/types/canvas/node.ts index 90df6d004..2989149fe 100644 --- a/packages/shared/src/types/canvas/node.ts +++ b/packages/shared/src/types/canvas/node.ts @@ -104,25 +104,48 @@ export function normalizeOrigin(raw: unknown): NodeOrigin | undefined { /** Who set the node label โ€” controls whether auto-title may overwrite it */ export type LabelSource = 'auto' | 'user' | 'agent'; +export type SpaceInstructionFrameKind = 'prompt' | 'skill'; + /** - * Whether a Frame label opts into Space-level agent instructions. + * Classify a label that opts a Frame into a Space-level instruction channel. * - * Prompt Frames are intentionally label-based in the first version so users - * and agents can create them through existing canvas operations. + * Instruction Frames are intentionally label-based so users and agents can + * create them through existing canvas operations. */ -export function isPromptFrameLabel(label: unknown): boolean { - return ( - typeof label === 'string' && - /^prompt(?:\s*:\s*\S[\s\S]*)?$/i.test(label.trim().normalize('NFC')) +export function classifySpaceInstructionFrameLabel( + label: unknown, +): SpaceInstructionFrameKind | null { + if (typeof label !== 'string') return null; + const match = /^(prompt|skill)(?:\s*:\s*\S[\s\S]*)?$/i.exec( + label.trim().normalize('NFC'), ); + const kind = match?.[1]?.toLowerCase(); + return kind === 'prompt' || kind === 'skill' ? kind : null; +} + +/** Only explicitly authored labels may activate instruction Frame semantics. */ +export function classifySpaceInstructionFrame( + label: unknown, + labelSource: unknown, +): SpaceInstructionFrameKind | null { + if (labelSource !== 'user' && labelSource !== 'agent') return null; + return classifySpaceInstructionFrameLabel(label); +} + +export function isPromptFrameLabel(label: unknown): boolean { + return classifySpaceInstructionFrameLabel(label) === 'prompt'; } -/** Only explicitly authored labels may activate Prompt Frame semantics. */ export function isPromptFrame(label: unknown, labelSource: unknown): boolean { - return ( - (labelSource === 'user' || labelSource === 'agent') && - isPromptFrameLabel(label) - ); + return classifySpaceInstructionFrame(label, labelSource) === 'prompt'; +} + +export function isSkillFrameLabel(label: unknown): boolean { + return classifySpaceInstructionFrameLabel(label) === 'skill'; +} + +export function isSkillFrame(label: unknown, labelSource: unknown): boolean { + return classifySpaceInstructionFrame(label, labelSource) === 'skill'; } /** Font family logical names. CSS font stacks are resolved on the UI side. */ From 2c50d012c9d772a79e92b69321571d0e3bf509b3 Mon Sep 17 00:00:00 2001 From: Yuqing Yang Date: Sat, 5 Sep 2026 02:36:16 +0000 Subject: [PATCH 3/7] docs(agent): clarify preamble persistence Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/architecture/agent-architecture.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture/agent-architecture.md b/docs/architecture/agent-architecture.md index 613531c00..f2989a6c1 100644 --- a/docs/architecture/agent-architecture.md +++ b/docs/architecture/agent-architecture.md @@ -117,7 +117,7 @@ Chat context uses an **envelope-first submission boundary** (see [agent-context. - [conversation/](../../apps/server/src/modules/agent/conversation) builds each turn's `ChatEnvelope` (user text + selection + anchor + skills). Before calling an agent handle, the selected host adapter renders that envelope into ordered canonical `AgentInput[]` and constructs `{ type: 'huabu.chat', content: envelope, rendered }`. - `AgentHandle.run(submission, ctx)` receives only data and live turn context; no host render closure crosses into Agenetes. One submission always remains one backend turn. pi preserves multiple canonical members through one atomic `agent.prompt(Message[])`; ACP flattens them in order into one `session/prompt`. - Agenetes owns conversation persistence per `(namespace, threadId)`: Tier 1 stores the complete submission plus streamed events, and Tier 2 stores the folded completed `AgentTurn`. The historical field remains named `request` for log compatibility but now carries the complete submission. Each driver decides how to lower that record back into its own channel: the built-in agent replays the canonical `rendered` input as native messages, ACP projects it to text, and the driver-level default (used only when a driver exposes no materializer) serializes `rendered` into a JSONL seed. All three fall back to the protocol form for older records written before `rendered` existed. -- **On disk (Chat-V2 two-tier log).** A canvas's namespace `storage.root` is its `.history/` dir (`canvasAcpNamespace(canvasId)`), so the log lives at `.history/chat_v2/.events.jsonl` โ€” Tier-1 append-only `AgentStreamEvent` delta log ([`FileEventLogStore`](../../external/agenetes/packages/agenetes/src/event-log.ts)) โ€” plus `.history/chat_v2/.turns.jsonl` โ€” Tier-2 folded `AgentTurn`, the **only** tier `history()` reads ([`FileTurnStore`](../../external/agenetes/packages/agenetes/src/turn-store.ts)). Durable workload records sit beside them in `.history/threads.json` ([`FileThreadStore`](../../external/agenetes/packages/agenetes/src/thread-store.ts)). The three file-backed stores plus the two drivers are wired once via `mountAgenetes` in [agenetes/drivers.ts](../../apps/server/src/modules/agent/agenetes/drivers.ts). Legacy chat files are folded into `chat_v2/` at workspace activation ([`migrate-chat-turns.ts`](../../apps/server/src/modules/storage/migrate-chat-turns.ts)). +- **On disk (Chat-V2 two-tier log).** A canvas's namespace `storage.root` is its `.history/` dir (`canvasAcpNamespace(canvasId)`), so the log lives at `.history/chat_v2/.events.jsonl` โ€” Tier-1 append-only `AgentStreamEvent` delta log ([`FileEventLogStore`](../../external/agenetes/packages/agenetes/src/event-log.ts)) โ€” plus `.history/chat_v2/.turns.jsonl` โ€” Tier-2 folded `AgentTurn`, the **only** tier `history()` reads ([`FileTurnStore`](../../external/agenetes/packages/agenetes/src/turn-store.ts)). These conversation logs deliberately contain the turn submission and assistant/tool transcript only; system prompts, ACP bootstrap text, fixed-node initial instructions, and captured Space Prompt are workload configuration rather than conversation parts and therefore do not appear in either Chat-V2 file. Durable workload records sit beside them in `.history/threads.json` ([`FileThreadStore`](../../external/agenetes/packages/agenetes/src/thread-store.ts)): built-in workloads keep the complete system prompt in `records[threadId].spec.spec.initialPreamble` and the separately recoverable Space Prompt snapshot in `hostContext.spacePrompt`, while ACP workloads keep ordered bootstrap, Space Prompt, and node-specific fragments in `initialPreamble`; ACP delivery state is `state.driverState.initialPreambleDelivered`. At runtime pi maps that preamble to its native system prompt, while ACP prefixes it to the first ordinary `session/prompt` only after the Chat-V2 turn boundary has captured the original submission. Consequently Chat-V2 is not an exact model-input audit log. With `HUABU_DEBUG_PROMPT` enabled, Huabu writes the fully assembled per-turn diagnostic prompt under the Space extension substrate `huabu.prompt.log/.prompt.log`. The three file-backed stores plus the two drivers are wired once via `mountAgenetes` in [agenetes/drivers.ts](../../apps/server/src/modules/agent/agenetes/drivers.ts). Legacy chat files are folded into `chat_v2/` at workspace activation ([`migrate-chat-turns.ts`](../../apps/server/src/modules/storage/migrate-chat-turns.ts)). - Durable workload records use the strict `agenetes-v2` format. Each record stores `driverSchemaVersion`, the complete opaque `WorkloadSpec`, and `AgentStateSnapshot { driverState, metadata? }`; malformed or unsupported files fail fast. Workspace activation migrates `agenetes-v1` files before any ThreadStore writer opens them, preserving the original as `threads.json.agenetes-v1.bak` and aborting without modification on an invalid record or unknown kind. The selected driver validates its own spec and state. The chat fork endpoint is temporarily unavailable because the existing request does not identify a complete target workload; it returns `501` until #321 defines the target-agent contract. - Moving an eligible Agent Node between Spaces preserves its `threadId` and uses Agenetes `rehome` to transfer the complete durable workload record, Tier-1 events, and Tier-2 turns between Canvas namespaces without creating a second conversation. Huabu rewrites the workload's target namespace and host-owned Canvas context, including ACP reachback environment, before rehome. A move is rejected while the thread is running or leased, when a Task Run owns its root node or thread, when pending change-review records exist, or when durable conversation state is missing or conflicting. Rehome writes the target logs before making the target record visible, removes the source only after target durability, and restores the source on a determinate failure. From 9f5456ff5839ece99d8a7b5dcdff18250c5c5565 Mon Sep 17 00:00:00 2001 From: Yuqing Yang Date: Sat, 5 Sep 2026 04:18:28 +0000 Subject: [PATCH 4/7] docs(agent): define external Agent realization Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/README.md | 43 +-- ...-agent-capability-cache-and-realization.md | 299 ++++++++++++++++++ 2 files changed, 321 insertions(+), 21 deletions(-) create mode 100644 docs/proposals/external-agent-capability-cache-and-realization.md diff --git a/docs/README.md b/docs/README.md index ae0b92f82..fcedc74fc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -77,27 +77,28 @@ docs/ ### Active -| Doc | Status | Summary | -| -------------------------------------------------------------------------------------------------- | -------------- | -------------------------------------------------------------------------------------- | -| [active-space-external-note-watcher.md](./proposals/active-space-external-note-watcher.md) | Proposed | Scope external-note watchers to Spaces with active SSE subscribers. | -| [agent-node-freshness-cas-plan.md](./proposals/agent-node-freshness-cas-plan.md) | In-Progress | Read/write revision freshness across agent and web paths. | -| [agent-turn-realtime-sync.md](./proposals/agent-turn-realtime-sync.md) | Proposed | Live attachment and durable event replay for UI, RFS, and Headless turns. | -| [canvas-checkpoint-plan.md](./proposals/canvas-checkpoint-plan.md) | Proposed | Canvas checkpoint and restoration design. | -| [canvas-realtime-sync-plan.md](./proposals/canvas-realtime-sync-plan.md) | In-Progress | Roadmap from multi-agent sync to multi-user co-editing. | -| [content-before-ai-design.md](./proposals/content-before-ai-design.md) | Needs review | Block-level and inline authorship provenance. | -| [credential-storage-hardening-followups.md](./proposals/credential-storage-hardening-followups.md) | Draft | Follow-up credential storage hardening. | -| [direct-space-operations.md](./proposals/direct-space-operations.md) | In-Progress | #348 deterministic RFS query and mutation operations for external agents. | -| [headless-executor-plan.md](./proposals/headless-executor-plan.md) | Partly shipped | Server-side headless canvas executor and structure/content sync. | -| [interactive-agent-views.md](./proposals/interactive-agent-views.md) | In-Progress | Capability-bound HTML views for persistent external-Agent interaction. | -| [long-horizon-tasks.md](./proposals/long-horizon-tasks.md) | Partly shipped | Canvas-scoped recursive Agent creation, invocation, and handoff pipeline. | -| [managed-acp-harness.md](./proposals/managed-acp-harness.md) | Draft | Resource-first Agent Team Profile compilation. | -| [managed-agent-teams.md](./proposals/managed-agent-teams.md) | In-Progress | Huabu-managed discovery, configuration, preparation, and runtime. | -| [milkdown-custom-toolbar-plan.md](./proposals/milkdown-custom-toolbar-plan.md) | In-Progress | Huabu-owned Milkdown toolbar and semantic editor commands. | -| [model-role-routing.md](./proposals/model-role-routing.md) | Proposed | Model selection by runtime role. | -| [move-selected-nodes-between-spaces.md](./proposals/move-selected-nodes-between-spaces.md) | Proposed | #142 selected-node and Frame-subtree moves between Spaces with bounded compensation. | -| [multi-backend-storage.md](./proposals/multi-backend-storage.md) | Partly shipped | Phases 1โ€“3: Blob, structured repositories, catalogue, and bounded reads. | -| [note-auto-height-stable-geometry.md](./proposals/note-auto-height-stable-geometry.md) | Proposed | Revision-aware offscreen Note measurement and stable auto-height geometry. | -| [space-preview-and-world-redesign.md](./proposals/space-preview-and-world-redesign.md) | In-Progress | View-only Space previews, a preview-based World, and deferred zoom-through navigation. | +| Doc | Status | Summary | +| -------------------------------------------------------------------------------------------------------------------- | -------------- | -------------------------------------------------------------------------------------- | +| [active-space-external-note-watcher.md](./proposals/active-space-external-note-watcher.md) | Proposed | Scope external-note watchers to Spaces with active SSE subscribers. | +| [agent-node-freshness-cas-plan.md](./proposals/agent-node-freshness-cas-plan.md) | In-Progress | Read/write revision freshness across agent and web paths. | +| [agent-turn-realtime-sync.md](./proposals/agent-turn-realtime-sync.md) | Proposed | Live attachment and durable event replay for UI, RFS, and Headless turns. | +| [canvas-checkpoint-plan.md](./proposals/canvas-checkpoint-plan.md) | Proposed | Canvas checkpoint and restoration design. | +| [canvas-realtime-sync-plan.md](./proposals/canvas-realtime-sync-plan.md) | In-Progress | Roadmap from multi-agent sync to multi-user co-editing. | +| [content-before-ai-design.md](./proposals/content-before-ai-design.md) | Needs review | Block-level and inline authorship provenance. | +| [credential-storage-hardening-followups.md](./proposals/credential-storage-hardening-followups.md) | Draft | Follow-up credential storage hardening. | +| [direct-space-operations.md](./proposals/direct-space-operations.md) | In-Progress | #348 deterministic RFS query and mutation operations for external agents. | +| [external-agent-capability-cache-and-realization.md](./proposals/external-agent-capability-cache-and-realization.md) | Accepted | #160/#162 GET-only capability discovery and canonical first-interaction realization. | +| [headless-executor-plan.md](./proposals/headless-executor-plan.md) | Partly shipped | Server-side headless canvas executor and structure/content sync. | +| [interactive-agent-views.md](./proposals/interactive-agent-views.md) | In-Progress | Capability-bound HTML views for persistent external-Agent interaction. | +| [long-horizon-tasks.md](./proposals/long-horizon-tasks.md) | Partly shipped | Canvas-scoped recursive Agent creation, invocation, and handoff pipeline. | +| [managed-acp-harness.md](./proposals/managed-acp-harness.md) | Draft | Resource-first Agent Team Profile compilation. | +| [managed-agent-teams.md](./proposals/managed-agent-teams.md) | In-Progress | Huabu-managed discovery, configuration, preparation, and runtime. | +| [milkdown-custom-toolbar-plan.md](./proposals/milkdown-custom-toolbar-plan.md) | In-Progress | Huabu-owned Milkdown toolbar and semantic editor commands. | +| [model-role-routing.md](./proposals/model-role-routing.md) | Proposed | Model selection by runtime role. | +| [move-selected-nodes-between-spaces.md](./proposals/move-selected-nodes-between-spaces.md) | Proposed | #142 selected-node and Frame-subtree moves between Spaces with bounded compensation. | +| [multi-backend-storage.md](./proposals/multi-backend-storage.md) | Partly shipped | Phases 1โ€“3: Blob, structured repositories, catalogue, and bounded reads. | +| [note-auto-height-stable-geometry.md](./proposals/note-auto-height-stable-geometry.md) | Proposed | Revision-aware offscreen Note measurement and stable auto-height geometry. | +| [space-preview-and-world-redesign.md](./proposals/space-preview-and-world-redesign.md) | In-Progress | View-only Space previews, a preview-based World, and deferred zoom-through navigation. | ### Shipped diff --git a/docs/proposals/external-agent-capability-cache-and-realization.md b/docs/proposals/external-agent-capability-cache-and-realization.md new file mode 100644 index 000000000..e428735b3 --- /dev/null +++ b/docs/proposals/external-agent-capability-cache-and-realization.md @@ -0,0 +1,299 @@ +# External Agent Capability Cache and Canonical Realization + +Status: Accepted +Last updated: 2026-09-05 + +## Context + +Issue [#160](https://github.com/microsoft/Huabu/issues/160) adds Space Prompt Frames whose content is frozen into a fixed Agent Node's durable WorkloadSpec when that Agent is first realized. + +Issue [#162](https://github.com/microsoft/Huabu/issues/162) removes UI-triggered ACP warm sessions and replaces them with GET-only Profile/Harness capability discovery. + +These changes share one lifecycle problem. Huabu currently starts an ACP session before the first user message so slash commands and selector metadata are available. A pre-message mode, model, or config-option change then obtains an Agenetes handle by calling `agenetes.create()` with a transport-minimal WorkloadSpec. Because Agenetes correctly treats a persisted Deployment spec as immutable and authoritative, that minimal spec can prevent the later message path from persisting the canonical fixed-node binding, launch overrides, node-specific instructions, and Space Prompt. + +The root problem is not Prompt Frame discovery and not ACP session creation itself. The system currently lets metadata discovery, live-session creation, mutable control state, and immutable workload realization overlap without one explicit ownership boundary. + +## Decision + +Huabu will separate capability discovery, workload realization, and live ACP session state. + +1. The Web reads last-observed external-agent capabilities from a GET-only Profile/Harness cache and never starts an ACP session merely to populate slash commands or selectors. +2. The first explicit Agent interaction realizes one complete canonical WorkloadSpec. An explicit interaction is either user input or a mode, model, or config-option control. +3. Control and message routes call the same server-owned realization pipeline and differ only after they receive the realized handle: control calls `handle.control()`, while a message calls `handle.run()`. +4. Fixed Agent Node identity is resolved from canonical Canvas state on the server. Client-supplied Profile or working-directory fields cannot override a fixed binding or its launch overrides. +5. ACP initial preamble remains pending after control-only realization and is delivered once by the driver on the first ordinary prompt. +6. WorkloadSpec is complete and immutable from its first durable write; mutable mode, model, config, session, and metadata state remains in the driver snapshot. +7. No compatibility migration or repair is required for bootstrap-only records created on the unshipped development branch. + +## Goals + +- Make opening an Agent Node, reading metadata, and opening slash-command UI side-effect free with respect to ACP process creation and durable workload creation. +- Preserve immediate UI rendering from cached commands and selector catalogues when prior observations exist. +- Allow a cold cache to degrade to empty/default UI without blocking the first user message. +- Ensure a first control and a first user message produce the same canonical WorkloadSpec. +- Snapshot the Space Prompt at the first explicit interaction for fixed Agent Nodes. +- Keep the existing Agenetes persisted-spec-authoritative invariant. +- Reconcile cached UI state with live agent reports after a real session starts. +- Keep permission-expanding selections safe when only stale or cross-thread observations exist. + +## Non-goals + +- Migrating or repairing bootstrap-only records created before this proposal ships. +- Guaranteeing that every harness publishes metadata before the first assistant response completes. +- Treating cached Profile/Harness observations as authoritative thread selections. +- Standardizing every harness on identical commands, models, modes, or config options. +- Changing Chat-V2 so control operations or initial preambles become conversation turns. +- Starting background capability-discovery processes solely to keep caches fresh. + +## Terminology and ownership + +| Concept | Owner | Lifetime | Durable | +| ------------------------------ | ---------------------------------------- | ------------------------------ | ---------------------------- | +| Profile | Huabu Profile registry | User-managed | Yes | +| Harness capability observation | Profile/Harness capability cache | Until refreshed or invalidated | Yes | +| Canonical WorkloadSpec | Agenetes ThreadStore | Thread lifetime | Yes | +| Driver selection and metadata | AgentStateSnapshot | Thread lifetime | Yes | +| ACP process/session | ACP driver session registry and Agentlet | Live runtime | Native session identity only | +| Chat conversation | Agenetes Tier-1/Tier-2 stores | Thread lifetime | Yes | + +A Profile is a launch configuration associated with a harness. Commands and selector catalogues are usually harness capabilities, but their exact values may also depend on harness version, account entitlement, placement, launch configuration, or workspace. The first implementation keeps observations Profile-associated because that is the narrowest existing identity that safely contains those differences. A later optimization may deduplicate catalogue data by a stronger harness capability key. + +## Capability cache + +### Cached data + +The cache stores the latest observed capability catalogue for a Profile: + +```typescript +interface ExternalAgentCapabilityObservation { + profileId: string; + commands: AvailableCommand[]; + availableModes: SessionMode[]; + availableModels: SessionModel[]; + configOptions: SessionConfigOption[]; + lastObservedValues: { + modeId?: string; + modelId?: string; + options?: Record; + }; + observedAt: number; +} +``` + +The concrete contract should reuse existing shared ACP metadata types rather than introduce parallel command or selector shapes. + +`lastObservedValues` describes what a prior real session reported. It is not a confirmed value for a new thread and does not become durable thread intent merely because the UI displayed it. + +### UI read path + +Opening an Agent Node performs a GET-only cache read: + +```text +Agent Node opens + -> resolve Profile identity + -> GET cached Profile capabilities + -> render catalogue and last-observed values + -> do not spawn an agent + -> do not create a WorkloadSpec +``` + +A cache miss returns a successful empty observation or an explicit `source: none` result. The UI may hide unavailable selectors, show neutral defaults, and leave slash commands empty. Chat remains usable. + +The Web no longer calls an ensure-session endpoint on cache miss, slash-menu open, metadata refresh, or ordinary panel mount. + +### Cache refresh + +When a real ACP session publishes commands, modes, models, or config options, Huabu updates both the active thread UI and the Profile capability cache. + +The cache is observational and last-write-wins. A removed model, mode, command, or option disappears when a newer complete catalogue supersedes it. Partial agent updates retain the existing merge semantics required by the ACP protocol. + +Permission-expanding values such as full access or auto-approve must not be presented as active for a new thread solely because they were last observed on another thread. They become active UI state only after the current session reports them or the current thread records a successful explicit selection. + +## Canonical workload realization + +### Trigger + +The first explicit Agent interaction triggers realization: + +- sending user input +- setting mode +- setting model +- setting a config option + +The following do not trigger realization: + +- opening an Agent Node +- reading capability cache +- opening slash-command UI +- reading commands or metadata +- restoring cached UI state + +### Realization output + +Realization creates one complete immutable WorkloadSpec containing: + +- thread identity, namespace, driver kind, and workload type +- authoritative Agent binding +- Profile recipe and explicit Agentlet placement +- reachback environment +- effective working directory +- mandatory Huabu bootstrap +- frozen Space Prompt for a fixed Agent Node +- node-specific additional initial instructions +- complete launch overrides +- a versioned Huabu realization marker + +The marker distinguishes a canonical workload from any future preparatory record without inspecting conversation logs or preamble contents. + +### Fixed and non-fixed threads + +For a fixed Agent Node, the server resolves the node by `(canvasId, threadId)` and treats its binding and launch overrides as authoritative. Client-supplied Profile and working-directory values are consistency hints only. A mismatch returns an explicit conflict response and does not create a workload. + +For a non-fixed external thread, the server uses the schema-validated requested binding and supported request configuration. It does not collect or inject a Space Prompt. + +### Shared entry point + +Control and message routes use one realization service: + +```typescript +const realized = await realizeExternalAgentThread({ + canvasId, + threadId, + requestedBinding, +}); + +if (interaction.type === 'control') { + return realized.handle.control(interaction.control); +} + +return realized.handle.run(interaction.submission, interaction.context); +``` + +The service reads an existing canonical record when present. Otherwise it resolves the target, collects the Prompt when eligible, builds the complete spec through the canonical ACP builder, persists it through Agenetes, and returns the realized handle. + +No control route may construct a reduced WorkloadSpec. + +## Realization and session boundaries + +The workload and ACP session have independent lifecycles: + +```text +Workload: unrealized -- first control/message --> realized and immutable +Session: absent ----- first control/message --> live <--> suspended/resumed +``` + +Removing UI-triggered warm sessions means a normal new thread has no ACP process before its first explicit interaction. The realization pipeline creates the canonical workload before the driver creates or resumes the real session. + +A control-only first interaction realizes the workload and creates the session but does not write a Chat-V2 turn and does not consume `initialPreamble`. The ACP driver delivers the ordered preamble once when the first ordinary prompt is lowered to `session/prompt`. + +Session suspension, resume, process restart, and native session recovery reuse the immutable WorkloadSpec and never recollect the Space Prompt. + +## Concurrency and failure + +Realization uses a short-lived gate keyed by Canvas namespace and thread ID. + +```text +first control ----\ + -> realization gate -> one canonical WorkloadSpec +first message ----/ +``` + +The gate covers target resolution, Prompt collection, spec construction, and durable creation. It is released before a long-running control or message operation. + +The existing turn lease continues to protect message execution and is not replaced by the realization gate. + +If realization fails before durable creation, no partial spec is written and a later explicit interaction may retry. If realization succeeds but a subsequent control is rejected, the finalized workload remains valid; workload identity and a mutable control result are separate concerns. + +## Selection semantics + +The UI resolves displayed values in this order: + +```text +current thread's confirmed explicit selection + > current live session report + > Profile cache last-observed value + > neutral harness/default presentation +``` + +Selecting a value before the first message is an explicit interaction. Huabu realizes the workload, creates the real session, validates the requested value against the live capability surface, sends the control, and records it only on success. + +Agents such as Copilot CLI may persist model selection themselves. Huabu does not assume a particular harness persistence scope. It displays the cached observation initially, then reconciles to what the new real session reports. + +## HTTP surface + +The final surface separates safe reads from side-effecting interactions: + +| Operation | Method semantics | Side effects | +| ----------------------------------- | ---------------- | --------------------------------------- | +| Read Profile capability observation | GET | None | +| Read realized thread metadata | GET | None | +| Send mode/model/config control | POST | May realize workload and create session | +| Send user input | POST/SSE | May realize workload and create session | + +The UI-triggered ensure-session endpoint is removed from normal product flow and may be deleted when no internal caller requires it. A GET endpoint must never spawn or resume an Agent. + +Every new or changed HTTP contract is defined once in `packages/shared/src/types/api`, validated with `safeParse` on the server, and imported type-only by the Web. + +## Implementation sequence + +Issue #162 is the implementation prerequisite for the #160 correction, but both ship in PR [#161](https://github.com/microsoft/Huabu/pull/161). + +### Phase 1: Cache-only capability UI + +- Expose one GET-only Profile capability observation contract. +- Reuse the existing Profile schema cache as the initial storage boundary. +- Make slash commands and session selectors read cached Profile observations without calling `ensureAcpSession`. +- Preserve cold-cache empty/default UI and live event reconciliation. +- Remove normal Web dependencies on `POST /threads/:threadId/session`. + +### Phase 2: Canonical realization service + +- Extract the shared external-thread realization pipeline. +- Resolve fixed targets from server-side Canvas state. +- Collect Space Prompt and apply launch overrides exactly once. +- Add a versioned realization marker to the canonical ACP host context. +- Add a namespace-and-thread realization gate. + +### Phase 3: Unify interactions + +- Route first and subsequent user messages through the realization service. +- Route mode, model, and config-option controls through the same service. +- Remove reduced WorkloadSpec construction from ACP control routes. +- Keep control operations outside Chat-V2 and preserve one-shot initial preamble delivery. + +### Phase 4: Remove obsolete warm-session flow + +- Delete unused Web API wrappers, polling, connection states, and ensure-session route code. +- Keep ACP driver session creation as an internal consequence of real control or run operations. +- Update architecture documentation to describe the shipped cache and realization boundaries. + +## Regression coverage + +- Opening an Agent Node, reading capability cache, and opening slash-command UI do not start an ACP session or create a ThreadRecord. +- A cold cache does not block the first user message. +- Cached commands and selectors render before a session exists. +- Live metadata reconciles the UI and refreshes the Profile cache. +- A first fixed-node control persists the same canonical spec as a first fixed-node message. +- Space Prompt and node-specific instructions are present after first-control realization. +- A first control does not write a Chat-V2 turn or consume the initial preamble. +- The first ordinary prompt delivers the preamble once. +- Subsequent controls mutate driver state without changing the WorkloadSpec. +- Concurrent first control and message requests create one canonical workload. +- Fixed binding mismatch is rejected without persisting a workload. +- Non-fixed external threads realize without a Space Prompt. +- Permission-expanding cached observations are not presented as confirmed current-thread state. + +## Code entry points + +| File/dir | Responsibility | +| ------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- | +| [`apps/server/src/modules/agent/agent-thread.service.ts`](../../apps/server/src/modules/agent/agent-thread.service.ts) | Current message-side fixed-target and Space Prompt orchestration; source for the shared realization boundary. | +| [`apps/server/src/modules/agent/agent-thread-resolver.ts`](../../apps/server/src/modules/agent/agent-thread-resolver.ts) | Canonical fixed Agent Node lookup and launch-override validation. | +| [`apps/server/src/modules/agent/acp/service.ts`](../../apps/server/src/modules/agent/acp/service.ts) | Canonical ACP WorkloadSpec builder and message execution. | +| [`apps/server/src/modules/agent/acp/threads.route.ts`](../../apps/server/src/modules/agent/acp/threads.route.ts) | Current session metadata and control routes; reduced spec creation and warm-session endpoints are removed from normal flow. | +| [`apps/server/src/modules/agent/acp/profile-schema-cache.ts`](../../apps/server/src/modules/agent/acp/profile-schema-cache.ts) | Existing Profile-associated capability observation cache. | +| [`apps/server/src/modules/agent/space-instruction-frames.ts`](../../apps/server/src/modules/agent/space-instruction-frames.ts) | Deterministic Space Prompt collection used during fixed-node realization. | +| [`apps/web/src/hooks/useAcpSessionMeta.ts`](../../apps/web/src/hooks/useAcpSessionMeta.ts) | Selector catalogue cache read and live-session reconciliation. | +| [`apps/web/src/hooks/useAcpSlashCommands.ts`](../../apps/web/src/hooks/useAcpSlashCommands.ts) | Slash-command cache read without session creation. | +| [`external/agenetes/packages/acp-driver/src/handle.ts`](../../external/agenetes/packages/acp-driver/src/handle.ts) | ACP session creation, control/run behavior, and one-shot preamble delivery. | +| [`external/agenetes/packages/agenetes/src/instance.ts`](../../external/agenetes/packages/agenetes/src/instance.ts) | Immutable persisted-spec and live-handle lifecycle. | From f5ab62e06a944f85f7701d55c296b0396094ff21 Mon Sep 17 00:00:00 2001 From: Yuqing Yang Date: Sat, 5 Sep 2026 05:28:56 +0000 Subject: [PATCH 5/7] fix(agent): canonicalize external agent realization Remove UI-triggered ACP warm sessions and make first controls and messages persist the same complete workload, including fixed-node Space Prompt context. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../acp/external-agent-realization.test.ts | 298 +++++++++++++ .../agent/acp/external-agent-realization.ts | 346 +++++++++++++++ .../modules/agent/acp/profile-schema-cache.ts | 25 +- apps/server/src/modules/agent/acp/service.ts | 48 +-- .../modules/agent/acp/threads.route.test.ts | 215 ++++++++++ .../src/modules/agent/acp/threads.route.ts | 400 ++++-------------- .../src/modules/agent/agenetes/drivers.ts | 8 +- .../agent/agent-thread.service.test.ts | 61 +-- .../src/modules/agent/agent-thread.service.ts | 59 ++- apps/server/src/modules/agent/agent.route.ts | 10 +- apps/web/src/api/_routes.ts | 6 - apps/web/src/api/acp.ts | 69 +-- .../Panels/ChatPanel/AcpConnectionBadge.tsx | 84 +--- .../ChatPanel/AcpSessionSelectors.test.tsx | 38 ++ .../Panels/ChatPanel/AcpSessionSelectors.tsx | 37 +- .../Panels/ChatPanel/SessionSelectorPill.tsx | 4 + .../src/components/Panels/ChatPanel/index.tsx | 76 ++-- apps/web/src/hooks/useAcpSessionMeta.test.tsx | 76 ++-- apps/web/src/hooks/useAcpSessionMeta.ts | 288 ++----------- .../src/hooks/useAcpSlashCommands.test.tsx | 127 ++++++ apps/web/src/hooks/useAcpSlashCommands.ts | 308 ++------------ docs/architecture/agent-architecture.md | 11 +- docs/architecture/agent-context.md | 3 +- ...-agent-capability-cache-and-realization.md | 33 +- .../agenetes-protocol.conformance.test.ts | 21 +- packages/shared/src/types/api/acp-tool.ts | 4 +- packages/shared/src/types/api/acp.ts | 278 +++--------- 27 files changed, 1511 insertions(+), 1422 deletions(-) create mode 100644 apps/server/src/modules/agent/acp/external-agent-realization.test.ts create mode 100644 apps/server/src/modules/agent/acp/external-agent-realization.ts create mode 100644 apps/server/src/modules/agent/acp/threads.route.test.ts create mode 100644 apps/web/src/hooks/useAcpSlashCommands.test.tsx diff --git a/apps/server/src/modules/agent/acp/external-agent-realization.test.ts b/apps/server/src/modules/agent/acp/external-agent-realization.test.ts new file mode 100644 index 000000000..201251b99 --- /dev/null +++ b/apps/server/src/modules/agent/acp/external-agent-realization.test.ts @@ -0,0 +1,298 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('../../workspace/paths.js', () => ({ + canvasAcpNamespace: (canvasId: string) => ({ + name: canvasId, + storage: { root: `/spaces/${canvasId}/.history` }, + }), +})); + +import { ExternalAgentRealizationService } from './external-agent-realization.js'; + +import type { ExternalAgentRealizationError } from './external-agent-realization.js'; +import type { AcpHandle, AcpWorkloadSpec } from '../agenetes/drivers.js'; +import type { FixedAgentNodeTarget } from '../agent-thread-resolver.js'; +import type { AcpSessionEntry } from '@agenetes/acp-driver'; +import type { ThreadRecord } from '@agenetes/agenetes'; +import type { CanvasNodeId } from '@huabu/shared'; +import type { FastifyBaseLogger } from 'fastify'; + +const logger = { + warn: vi.fn(), +} as unknown as FastifyBaseLogger; + +const target: FixedAgentNodeTarget = { + canvasId: 'canvas-1', + nodeId: 'node-1' as CanvasNodeId, + threadId: 'thread-1', + agentBinding: { + kind: 'external', + alias: 'Fixed Agent', + profileId: 'profile-fixed', + }, + launchOverrides: { + workingDirPath: '/fixed/work', + additionalInitialPreamble: 'Node instructions', + }, + status: 'idle', + content: '', +}; +const targetBinding = target.agentBinding as Extract< + typeof target.agentBinding, + { kind: 'external' } +>; + +function createHarness(options?: { + record?: ThreadRecord; + collect?: () => Promise<{ + markdown: string; + diagnostics: { + includedFrameIds: string[]; + includedNodeIds: string[]; + omittedUnsupportedIds: string[]; + omittedEmptyTextIds: string[]; + omittedMissingIds: string[]; + truncated: boolean; + }; + } | null>; +}) { + const handle = { + control: vi.fn().mockResolvedValue({ ok: true }), + } as unknown as AcpHandle; + const createHandle = vi.fn(() => handle); + const buildSpec = vi.fn( + ({ + binding, + threadId, + canvasId, + launchOverrides, + spacePrompt, + cwd, + }: { + binding: { alias: string; profileId: string }; + threadId: string; + canvasId?: string; + cwd?: string; + launchOverrides?: { + workingDirPath?: string; + additionalInitialPreamble?: string; + }; + spacePrompt?: string; + }): AcpWorkloadSpec => ({ + threadId, + namespace: { + name: canvasId ?? '', + storage: { root: `/spaces/${canvasId ?? ''}/.history` }, + }, + kind: 'external', + workloadType: 'Deployment', + spec: { + binding, + agentletId: 'agentlet-1', + cwd: launchOverrides?.workingDirPath ?? cwd, + recipe: null, + initialPreamble: [ + 'Huabu bootstrap', + ...(spacePrompt ? [spacePrompt] : []), + ...(launchOverrides?.additionalInitialPreamble + ? [launchOverrides.additionalInitialPreamble] + : []), + ], + }, + }), + ); + const ensureSession = vi.fn().mockResolvedValue({ + profileId: 'profile-fixed', + configOptions: [], + } as unknown as AcpSessionEntry); + const collectSpacePrompt = + options?.collect ?? + vi.fn().mockResolvedValue({ + markdown: 'Space rules', + diagnostics: { + includedFrameIds: [], + includedNodeIds: [], + omittedUnsupportedIds: [], + omittedEmptyTextIds: [], + omittedMissingIds: [], + truncated: false, + }, + }); + const service = new ExternalAgentRealizationService({ + resolveFixedAgentNode: vi.fn().mockResolvedValue(target), + collectSpacePrompt, + readRecord: vi.fn(() => options?.record), + createHandle, + buildSpec, + subscribeProfileCache: vi.fn(), + ensureSession, + }); + return { + service, + handle, + createHandle, + buildSpec, + collectSpacePrompt, + ensureSession, + }; +} + +describe('ExternalAgentRealizationService', () => { + it('realizes first control with the fixed Space Prompt and node instructions', async () => { + const harness = createHarness(); + const realized = await harness.service.realize({ + threadId: 'thread-1', + canvasId: 'canvas-1', + requestedBinding: targetBinding, + fixedTarget: target, + logger, + }); + + await harness.service.ensureSession(realized, logger); + await realized.handle.control({ + type: 'set_mode', + data: { modeId: 'plan' }, + }); + + expect(realized.spec.spec).toMatchObject({ + binding: { + alias: 'Fixed Agent', + profileId: 'profile-fixed', + }, + cwd: '/fixed/work', + initialPreamble: [ + 'Huabu bootstrap', + 'Space rules', + 'Node instructions', + ], + }); + expect(harness.ensureSession).toHaveBeenCalledWith(realized, logger); + expect(harness.handle.control).toHaveBeenCalledOnce(); + }); + + it('rejects a fixed Profile mismatch before creating a workload', async () => { + const harness = createHarness(); + + await expect( + harness.service.realize({ + threadId: 'thread-1', + canvasId: 'canvas-1', + requestedBinding: { + kind: 'external', + alias: 'Other', + profileId: 'profile-other', + }, + fixedTarget: target, + logger, + }), + ).rejects.toMatchObject({ + code: 'external_binding_conflict', + } satisfies Partial); + expect(harness.collectSpacePrompt).not.toHaveBeenCalled(); + expect(harness.createHandle).not.toHaveBeenCalled(); + }); + + it('rejects a fixed working-directory mismatch before creating a workload', async () => { + const harness = createHarness(); + + await expect( + harness.service.realize({ + threadId: 'thread-1', + canvasId: 'canvas-1', + requestedBinding: targetBinding, + requestedCwd: '/client/override', + fixedTarget: target, + logger, + }), + ).rejects.toMatchObject({ + code: 'external_working_directory_conflict', + } satisfies Partial); + expect(harness.createHandle).not.toHaveBeenCalled(); + }); + + it('reuses the persisted canonical workload without recollecting Prompt Frames', async () => { + const persisted: AcpWorkloadSpec = { + threadId: 'thread-1', + namespace: { + name: 'canvas-1', + storage: { root: '/spaces/canvas-1/.history' }, + }, + kind: 'external', + workloadType: 'Deployment', + spec: { + binding: { alias: 'Fixed Agent', profileId: 'profile-fixed' }, + agentletId: 'agentlet-1', + cwd: '/fixed/work', + recipe: null, + initialPreamble: [ + 'Huabu bootstrap', + 'Original rules', + 'Node instructions', + ], + }, + }; + const harness = createHarness({ + record: { + driverSchemaVersion: 1, + spec: persisted, + state: { driverState: { initialPreambleDelivered: false } }, + }, + }); + + const realized = await harness.service.realize({ + threadId: 'thread-1', + canvasId: 'canvas-1', + requestedBinding: targetBinding, + fixedTarget: target, + logger, + }); + + expect(realized.spec).toBe(persisted); + expect(harness.buildSpec).not.toHaveBeenCalled(); + expect(harness.collectSpacePrompt).not.toHaveBeenCalled(); + }); + + it('single-flights simultaneous first interactions', async () => { + let releaseCollection!: () => void; + const collectionGate = new Promise((resolve) => { + releaseCollection = resolve; + }); + const collect = vi.fn(async () => { + await collectionGate; + return { + markdown: 'Space rules', + diagnostics: { + includedFrameIds: [], + includedNodeIds: [], + omittedUnsupportedIds: [], + omittedEmptyTextIds: [], + omittedMissingIds: [], + truncated: false, + }, + }; + }); + const harness = createHarness({ collect }); + const options = { + threadId: 'thread-1', + canvasId: 'canvas-1', + requestedBinding: targetBinding, + fixedTarget: target, + logger, + }; + + const first = harness.service.realize(options); + const second = harness.service.realize(options); + await Promise.resolve(); + expect(collect).toHaveBeenCalledOnce(); + + releaseCollection(); + const [firstResult, secondResult] = await Promise.all([first, second]); + + expect(firstResult.spec).toBe(secondResult.spec); + expect(harness.buildSpec).toHaveBeenCalledOnce(); + expect(harness.createHandle).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/server/src/modules/agent/acp/external-agent-realization.ts b/apps/server/src/modules/agent/acp/external-agent-realization.ts new file mode 100644 index 000000000..2d05d04d8 --- /dev/null +++ b/apps/server/src/modules/agent/acp/external-agent-realization.ts @@ -0,0 +1,346 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + AcpServiceError, + ensureAcpSession, + resolveAcpAgentletId, +} from '@agenetes/acp-driver'; + +import { canvasAcpNamespace } from '../../workspace/paths.js'; +import { + acpRuntimePolicy, + agenetes, + EXTERNAL_DRIVER_KIND, + type AcpHandle, + type AcpWorkloadSpec, +} from '../agenetes/drivers.js'; +import { + agentThreadResolver, + type FixedAgentNodeTarget, +} from '../agent-thread-resolver.js'; +import { resolveSpacePrompt } from '../space-instruction-frames.js'; +import { ensureProfileCacheSubscription } from './profile-cache-port.js'; +import { getExternalAgentRuntimeConfig } from './runtime-config.js'; +import { buildAcpWorkloadSpec } from './service.js'; + +import type { AcpSessionEntry } from '@agenetes/acp-driver'; +import type { Namespace } from '@agenetes/protocol'; +import type { AgentBinding } from '@huabu/shared'; +import type { FastifyBaseLogger } from 'fastify'; + +type ExternalBinding = Extract; + +export type ExternalAgentRealizationErrorCode = + | 'external_binding_required' + | 'external_binding_conflict' + | 'external_working_directory_conflict' + | 'external_thread_kind_conflict'; + +export class ExternalAgentRealizationError extends Error { + constructor( + public readonly code: ExternalAgentRealizationErrorCode, + message: string, + ) { + super(message); + this.name = 'ExternalAgentRealizationError'; + } +} + +export interface RealizeExternalAgentThreadOptions { + threadId: string; + canvasId?: string; + requestedBinding?: ExternalBinding; + requestedCwd?: string; + fixedTarget?: FixedAgentNodeTarget | null; + logger: FastifyBaseLogger; +} + +export interface RealizedExternalAgentThread { + binding: ExternalBinding; + fixedTarget: FixedAgentNodeTarget | null; + spec: AcpWorkloadSpec; + handle: AcpHandle; +} + +interface RealizationDependencies { + resolveFixedAgentNode: ( + canvasId: string, + threadId: string, + ) => Promise; + collectSpacePrompt: typeof resolveSpacePrompt; + readRecord: ( + namespace: Namespace, + threadId: string, + ) => ReturnType; + createHandle: (spec: AcpWorkloadSpec) => AcpHandle; + buildSpec: typeof buildAcpWorkloadSpec; + subscribeProfileCache: typeof ensureProfileCacheSubscription; + ensureSession: ( + realized: RealizedExternalAgentThread, + logger: FastifyBaseLogger, + ) => Promise; +} + +function bindingFromSpec(spec: AcpWorkloadSpec): ExternalBinding { + return { + kind: 'external', + alias: spec.spec.binding.alias, + profileId: spec.spec.binding.profileId, + }; +} + +async function ensureSessionFromCanonicalSpec( + realized: RealizedExternalAgentThread, + logger: FastifyBaseLogger, +): Promise { + const { spec } = realized; + const resolvedEnvironment = + await acpRuntimePolicy.resolveRuntimeEnvironment?.(spec.spec); + const env = + resolvedEnvironment || spec.spec.env + ? { ...resolvedEnvironment, ...spec.spec.env } + : undefined; + const record = agenetes.record(spec.namespace, spec.threadId); + return ensureAcpSession({ + agentletId: resolveAcpAgentletId(spec), + threadId: spec.threadId, + binding: spec.spec.binding, + namespace: spec.namespace, + ...(spec.spec.cwd !== undefined && { cwd: spec.spec.cwd }), + ...(spec.spec.recipe !== undefined && { recipe: spec.spec.recipe }), + ...(env !== undefined && { env }), + ...(record?.state !== undefined && { + priorState: record.state as Parameters< + typeof ensureAcpSession + >[0]['priorState'], + }), + ...(spec.spec.initialPreferences !== undefined && { + initialPreferences: spec.spec.initialPreferences, + }), + idleTimeoutSecs: getExternalAgentRuntimeConfig().idleTimeoutSecs, + logger, + }); +} + +const DEFAULT_DEPENDENCIES: RealizationDependencies = { + resolveFixedAgentNode: (canvasId, threadId) => + agentThreadResolver.resolveFixedAgentNode(canvasId, threadId), + collectSpacePrompt: resolveSpacePrompt, + readRecord: (namespace, threadId) => agenetes.record(namespace, threadId), + createHandle: (spec) => agenetes.create(spec) as AcpHandle, + buildSpec: buildAcpWorkloadSpec, + subscribeProfileCache: ensureProfileCacheSubscription, + ensureSession: ensureSessionFromCanonicalSpec, +}; + +export class ExternalAgentRealizationService { + private readonly inFlight = new Map< + string, + Promise + >(); + + constructor( + private readonly dependencies: RealizationDependencies = DEFAULT_DEPENDENCIES, + ) {} + + async realize( + options: RealizeExternalAgentThreadOptions, + ): Promise { + const namespace = canvasAcpNamespace(options.canvasId ?? ''); + const key = `${namespace.name}\u0000${namespace.storage?.root ?? ''}\u0000${options.threadId}`; + let pending = this.inFlight.get(key); + if (!pending) { + pending = this.realizeOnce(options, namespace); + this.inFlight.set(key, pending); + } + try { + const realized = await pending; + this.validateRequest(realized, options); + return realized; + } finally { + if (this.inFlight.get(key) === pending) this.inFlight.delete(key); + } + } + + ensureSession( + realized: RealizedExternalAgentThread, + logger: FastifyBaseLogger, + ): Promise { + return this.dependencies.ensureSession(realized, logger); + } + + private async realizeOnce( + options: RealizeExternalAgentThreadOptions, + namespace: Namespace, + ): Promise { + const fixedTarget = + options.fixedTarget === undefined + ? options.canvasId + ? await this.dependencies.resolveFixedAgentNode( + options.canvasId, + options.threadId, + ) + : null + : options.fixedTarget; + const record = this.dependencies.readRecord(namespace, options.threadId); + + if (record) { + if (record.spec.kind !== EXTERNAL_DRIVER_KIND) { + throw new ExternalAgentRealizationError( + 'external_thread_kind_conflict', + `Thread ${options.threadId} is already realized with a non-external agent`, + ); + } + const spec = record.spec as AcpWorkloadSpec; + const binding = bindingFromSpec(spec); + const realized = { + binding, + fixedTarget, + spec, + handle: this.dependencies.createHandle(spec), + }; + this.dependencies.subscribeProfileCache( + options.threadId, + binding.profileId, + ); + return realized; + } + + const binding = fixedTarget?.agentBinding ?? options.requestedBinding; + if (!binding || binding.kind !== 'external') { + throw new ExternalAgentRealizationError( + 'external_binding_required', + `Thread ${options.threadId} has no external Agent binding`, + ); + } + + if ( + fixedTarget && + options.requestedBinding && + options.requestedBinding.profileId !== binding.profileId + ) { + throw new ExternalAgentRealizationError( + 'external_binding_conflict', + `Thread ${options.threadId} is fixed to Profile ${binding.profileId}`, + ); + } + + const collected = fixedTarget + ? await this.dependencies.collectSpacePrompt(fixedTarget.canvasId) + : null; + if ( + collected && + (collected.diagnostics.truncated || + collected.diagnostics.omittedUnsupportedIds.length > 0 || + collected.diagnostics.omittedEmptyTextIds.length > 0 || + collected.diagnostics.omittedMissingIds.length > 0) + ) { + options.logger.warn( + { + canvasId: fixedTarget?.canvasId, + threadId: options.threadId, + spacePromptDiagnostics: collected.diagnostics, + }, + 'Space Prompt collection completed with diagnostics', + ); + } + + const spec = this.dependencies.buildSpec({ + binding, + threadId: options.threadId, + canvasId: options.canvasId, + cwd: fixedTarget ? undefined : options.requestedCwd, + ...(fixedTarget?.launchOverrides + ? { launchOverrides: fixedTarget.launchOverrides } + : {}), + spacePrompt: collected?.markdown, + }); + if ( + fixedTarget && + options.requestedCwd !== undefined && + options.requestedCwd !== spec.spec.cwd + ) { + throw new ExternalAgentRealizationError( + 'external_working_directory_conflict', + `Thread ${options.threadId} is fixed to working directory ${spec.spec.cwd ?? '(profile default)'}`, + ); + } + + const realized = { + binding, + fixedTarget, + spec, + handle: this.dependencies.createHandle(spec), + }; + this.dependencies.subscribeProfileCache( + options.threadId, + binding.profileId, + ); + return realized; + } + + private validateRequest( + realized: RealizedExternalAgentThread, + options: RealizeExternalAgentThreadOptions, + ): void { + const fixedBinding = realized.fixedTarget?.agentBinding; + if ( + fixedBinding && + (fixedBinding.kind !== 'external' || + fixedBinding.profileId !== realized.binding.profileId) + ) { + throw new ExternalAgentRealizationError( + 'external_binding_conflict', + `Fixed Agent Node for thread ${options.threadId} does not match its realized Profile`, + ); + } + const fixedCwd = realized.fixedTarget?.launchOverrides?.workingDirPath; + if (fixedCwd !== undefined && fixedCwd !== realized.spec.spec.cwd) { + throw new ExternalAgentRealizationError( + 'external_working_directory_conflict', + `Fixed Agent Node for thread ${options.threadId} does not match its realized working directory`, + ); + } + if ( + options.requestedBinding && + options.requestedBinding.profileId !== realized.binding.profileId + ) { + throw new ExternalAgentRealizationError( + 'external_binding_conflict', + `Thread ${options.threadId} is realized with Profile ${realized.binding.profileId}`, + ); + } + if ( + options.requestedCwd !== undefined && + options.requestedCwd !== realized.spec.spec.cwd + ) { + throw new ExternalAgentRealizationError( + 'external_working_directory_conflict', + `Thread ${options.threadId} is realized with working directory ${realized.spec.spec.cwd ?? '(profile default)'}`, + ); + } + } +} + +export function realizationHttpError(error: unknown): { + status: 409 | 503; + body: { message: string; code: string }; +} { + if (error instanceof ExternalAgentRealizationError) { + return { + status: 409, + body: { message: error.message, code: error.code }, + }; + } + const message = error instanceof Error ? error.message : String(error); + return { + status: 503, + body: { + message, + code: error instanceof AcpServiceError ? error.code : 'internal', + }, + }; +} + +export const externalAgentRealization = new ExternalAgentRealizationService(); diff --git a/apps/server/src/modules/agent/acp/profile-schema-cache.ts b/apps/server/src/modules/agent/acp/profile-schema-cache.ts index 1d1f865cb..23aea2c8e 100644 --- a/apps/server/src/modules/agent/acp/profile-schema-cache.ts +++ b/apps/server/src/modules/agent/acp/profile-schema-cache.ts @@ -7,22 +7,17 @@ * * ### Motivation * - * The per-`(canvasId, threadId)` cache in `session-store` requires - * spawning the agent at least once per thread before the toolbar - * selectors (model / mode / config option) can populate. But for any - * given profile (e.g. "Copilot @ ~/projects/foo"), the schema portion - * of the meta โ€” `availableModels`, `availableModes`, `configOptions` - * shape โ€” is **identical across every thread bound to that profile**. + * The per-thread durable snapshot exists only after realization. This cache + * lets unopened threads render the last observed Profile capability catalogue + * without spawning ACP or creating a WorkloadSpec. * The `current*` values are per-thread state and are retained only so an * already-associated thread snapshot can be reconstructed elsewhere. They * are never authoritative for a brand-new thread. * - * By caching the most recent push from any session of a profile, the - * cache can still identify a known profile catalogue. A brand-new command - * thread opens a real session before rendering active values; a manifest - * thread waits for its first unified turn. This prevents another thread's - * last-known auto-approve value from being presented as the new session's - * effective policy. + * By caching the most recent push from any session of a Profile, the cache can + * identify a known catalogue. Mode/model values may be displayed as last + * observed, while generic config-option values remain unconfirmed until the + * current thread reports them or records a successful explicit selection. * * ### What gets cached * @@ -36,7 +31,7 @@ * agent's slash-command catalogue is effectively static per profile * (e.g. Copilot CLI advertises the same ~34 commands across every * session), so caching the last-seen list lets a brand-new thread - * paint its `/` menu instantly on warm spawn. The agent's authoritative + * paint its `/` menu without a warm spawn. The agent's authoritative * `available_commands_update` push silently overwrites the cached * list once it arrives, so any per-session drift (e.g. a `/load` * variant exposed only on resumed sessions) self-corrects on the @@ -315,8 +310,8 @@ export function invalidateProfileSchemaCache(profileId: string): void { * `available_commands_update` replaces the cached list wholesale on the * next session, so any per-session drift self-corrects. * - * The cache is what `/cached-meta` falls back to when a brand-new thread - * has no per-thread durable record โ€” see `threads.route.ts`. + * The cache is what the GET-only `/cached-meta` route falls back to when a + * brand-new thread has no per-thread durable record. */ export function foldMetadataIntoProfileCache( profileId: string, diff --git a/apps/server/src/modules/agent/acp/service.ts b/apps/server/src/modules/agent/acp/service.ts index 8f0dcb296..f97ecd111 100644 --- a/apps/server/src/modules/agent/acp/service.ts +++ b/apps/server/src/modules/agent/acp/service.ts @@ -26,14 +26,12 @@ import { } from '@agenetes/agentlet-host'; import { renderExternalAgentInputs } from './preprocessor.js'; -import { ensureProfileCacheSubscription } from './profile-cache-port.js'; import { getProfileSessionPreferences } from './profile-session-preferences.js'; import { getProfile as getLegacyProfile } from './profile-store.js'; import { buildReachbackEnv } from './reachback-env.js'; import { renderExternalAgentSystemPreamble } from '../../../prompt/external-agent/system-preamble.js'; import { canvasAcpNamespace } from '../../workspace/paths.js'; import { - agenetes, EXTERNAL_DRIVER_KIND, type AcpHandle, type AcpWorkloadSpec, @@ -49,6 +47,8 @@ import type { AgentLaunchOverrides, AgentStreamEvent } from '@huabu/shared'; import type { FastifyBaseLogger } from 'fastify'; export interface RunAcpAgentOptions { + /** Canonically realized handle shared by message and control paths. */ + handle: AcpHandle; /** * External binding for the active thread. `profileId` references a * user-configured spawn recipe (see `./profile-store.ts`); the @@ -101,10 +101,6 @@ export interface RunAcpAgentOptions { * stranded at the filesystem root). */ cwd?: string; - /** Per-node spawn overrides applied when the workload is first created. */ - launchOverrides?: AgentLaunchOverrides; - /** Frozen Space Prompt captured when a fixed Agent Node is first realised. */ - spacePrompt?: string; /** Cancellation signal \u2014 wired through to `session/cancel`. */ signal?: AbortSignal; logger: FastifyBaseLogger; @@ -195,16 +191,17 @@ function applyWorkingDirectoryOverride( }; } +export interface BuildAcpWorkloadSpecOptions { + binding: { alias: string; profileId: string }; + threadId: string; + canvasId?: string; + cwd?: string; + launchOverrides?: AgentLaunchOverrides; + spacePrompt?: string; +} + export function buildAcpWorkloadSpec( - opts: Pick< - RunAcpAgentOptions, - | 'binding' - | 'threadId' - | 'canvasId' - | 'cwd' - | 'launchOverrides' - | 'spacePrompt' - >, + opts: BuildAcpWorkloadSpecOptions, ): AcpWorkloadSpec { const { binding, threadId } = opts; const canvasId = opts.canvasId ?? ''; @@ -269,7 +266,7 @@ export function buildAcpWorkloadSpec( export async function* runAcpAgent( opts: RunAcpAgentOptions, ): AsyncGenerator { - const { binding, threadId, overlay, signal, logger } = opts; + const { binding, overlay, signal, logger, handle } = opts; const canvasId = opts.canvasId ?? ''; const submission = opts.submission ?? @@ -283,13 +280,6 @@ export async function* runAcpAgent( }), ); - // Bake this thread's WorkloadSpec (I9.6). The ACP handle self-resolves - // (opens or reuses) its live session per turn from these fields โ€” L1 no - // longer opens the session out-of-band. Agenetes keeps an existing - // persisted spec authoritative when recovering a previously created - // workload. - const spec = buildAcpWorkloadSpec(opts); - // Optional developer aid: dump the exact text payload handed to ACP // `session/prompt` (the serialized prompt, NOT pi-ai messages โ€” the // external agent keeps its own session history). No-op unless @@ -313,15 +303,9 @@ export async function* runAcpAgent( } : undefined; - // Get-or-create the long-lived ACP handle for this thread (I9.3) and - // drive one turn. The handle self-resolves its session inside `run`, so - // session-open failures surface on the generator's first `next()`. - // Static DriverMap construction guarantees that `external` is ACP. - const handle = agenetes.create(spec) as AcpHandle; - // Fold this thread's up-reported metadata into the L1 profile cache - // (I9.7). Idempotent per thread โ€” subscribing before `run()` so the - // handle's initial state up-report is captured. - ensureProfileCacheSubscription(threadId, binding.profileId); + // The shared realization service has already created the complete durable + // workload and subscribed its metadata before either message or control + // dispatch reaches this point. const iterator = handle.run(submission, { overlay, signal, diff --git a/apps/server/src/modules/agent/acp/threads.route.test.ts b/apps/server/src/modules/agent/acp/threads.route.test.ts new file mode 100644 index 000000000..022ee67f6 --- /dev/null +++ b/apps/server/src/modules/agent/acp/threads.route.test.ts @@ -0,0 +1,215 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + live: undefined as unknown, + record: undefined as unknown, + profileCache: undefined as unknown, + realize: vi.fn(), + ensureSession: vi.fn(), + control: vi.fn(), + create: vi.fn(), +})); + +vi.mock('@agenetes/acp-driver', () => ({ + acpSessionRegistry: { get: () => mocks.live }, +})); + +vi.mock('@agenetes/agentlet-host', () => ({ + getSupervisedAgentletId: () => 'agentlet-1', +})); + +vi.mock('./external-agent-realization.js', () => ({ + externalAgentRealization: { + realize: mocks.realize, + ensureSession: mocks.ensureSession, + }, + realizationHttpError: (error: unknown) => ({ + status: 503, + body: { message: String(error), code: 'internal' }, + }), +})); + +vi.mock('./profile-schema-cache.js', () => ({ + getProfileSchemaCache: () => mocks.profileCache, +})); + +vi.mock('./profile-session-preferences.js', () => ({ + rememberProfileConfigPreference: vi.fn(), + rememberProfileSessionPreference: vi.fn(), +})); + +vi.mock('../../workspace/paths.js', () => ({ + canvasAcpNamespace: (canvasId: string) => ({ name: canvasId }), +})); + +vi.mock('../agenetes/index.js', () => ({ + agenetes: { + record: () => mocks.record, + get: vi.fn(), + create: mocks.create, + }, +})); + +import acpThreadsRoutes from './threads.route.js'; + +let app: FastifyInstance | undefined; + +afterEach(async () => { + await app?.close(); + app = undefined; + mocks.live = undefined; + mocks.record = undefined; + mocks.profileCache = undefined; + mocks.realize.mockReset(); + mocks.ensureSession.mockReset(); + mocks.control.mockReset(); + mocks.create.mockReset(); +}); + +async function createApp(): Promise { + app = Fastify({ logger: false }); + await app.register(acpThreadsRoutes, { prefix: '/api/acp' }); + return app; +} + +describe('ACP cached capability route', () => { + it('projects commands and selector catalogues from the Profile cache', async () => { + mocks.profileCache = { + availableCommands: [{ name: 'review', description: 'Review changes' }], + commandsUpdatedAt: 11, + availableModes: [{ id: 'plan', name: 'Plan' }], + currentModeId: 'plan', + availableModels: [{ modelId: 'model-1', name: 'Model 1' }], + currentModelId: 'model-1', + configOptions: [ + { + id: 'allow_all', + name: 'Auto approve', + type: 'boolean', + currentValue: true, + }, + ], + metaUpdatedAt: 12, + }; + const server = await createApp(); + + const response = await server.inject({ + method: 'GET', + url: '/api/acp/threads/thread-1/cached-meta?canvasId=canvas-1&profileId=profile-1', + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + source: 'profile', + availableCommands: [{ name: 'review' }], + commandsUpdatedAt: 11, + sessionMeta: { + currentModeId: 'plan', + currentModelId: 'model-1', + selections: {}, + updatedAt: 12, + }, + }); + expect(mocks.create).not.toHaveBeenCalled(); + }); + + it('returns a successful empty observation on a cold cache', async () => { + const server = await createApp(); + + const response = await server.inject({ + method: 'GET', + url: '/api/acp/threads/thread-1/cached-meta?canvasId=canvas-1&profileId=profile-1', + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + source: 'none', + availableCommands: [], + commandsUpdatedAt: 0, + sessionMeta: { updatedAt: 0 }, + }); + expect(mocks.create).not.toHaveBeenCalled(); + }); + + it('returns Profile commands when the agent has not published session metadata', async () => { + mocks.profileCache = { + availableCommands: [{ name: 'review', description: 'Review changes' }], + commandsUpdatedAt: 11, + metaUpdatedAt: 0, + }; + const server = await createApp(); + + const response = await server.inject({ + method: 'GET', + url: '/api/acp/threads/thread-1/cached-meta?canvasId=canvas-1&profileId=profile-1', + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + source: 'profile', + availableCommands: [{ name: 'review' }], + commandsUpdatedAt: 11, + sessionMeta: { updatedAt: 0 }, + }); + expect(mocks.create).not.toHaveBeenCalled(); + }); + + it('realizes and ensures the canonical workload before a first control', async () => { + const realized = { + binding: { + kind: 'external', + alias: 'Fixed Agent', + profileId: 'profile-fixed', + }, + fixedTarget: null, + spec: { spec: { initialPreamble: ['Bootstrap', 'Space', 'Node'] } }, + handle: { control: mocks.control }, + }; + mocks.realize.mockResolvedValue(realized); + mocks.ensureSession.mockResolvedValue({ + profileId: 'profile-fixed', + configOptions: [], + }); + mocks.control.mockResolvedValue({ ok: true }); + const server = await createApp(); + + const response = await server.inject({ + method: 'POST', + url: '/api/acp/threads/thread-1/mode', + payload: { + modeId: 'plan', + binding: { + kind: 'external', + alias: 'Fixed Agent', + profileId: 'profile-fixed', + }, + canvasId: 'canvas-1', + }, + }); + + expect(response.statusCode).toBe(200); + expect(mocks.realize).toHaveBeenCalledWith( + expect.objectContaining({ + threadId: 'thread-1', + canvasId: 'canvas-1', + requestedBinding: { + kind: 'external', + alias: 'Fixed Agent', + profileId: 'profile-fixed', + }, + }), + ); + expect(mocks.ensureSession).toHaveBeenCalledWith( + realized, + expect.any(Object), + ); + expect(mocks.control).toHaveBeenCalledWith({ + type: 'set_mode', + data: { modeId: 'plan' }, + }); + }); +}); diff --git a/apps/server/src/modules/agent/acp/threads.route.ts b/apps/server/src/modules/agent/acp/threads.route.ts index ea773135a..a577b1d1b 100644 --- a/apps/server/src/modules/agent/acp/threads.route.ts +++ b/apps/server/src/modules/agent/acp/threads.route.ts @@ -1,61 +1,28 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -/** - * `POST /api/acp/threads/:threadId/session` โ€” eagerly open (or reuse) the - * per-thread ACP session so the web client can pull slash commands BEFORE - * the user submits their first prompt. - * - * `GET /api/acp/threads/:threadId/commands` โ€” return the cached - * `available_commands_update` snapshot for an existing session (404 if - * no session has been opened for this thread yet). - * - * Why a dedicated route family (instead of widening `agents.route.ts`): - * - These endpoints are thread-scoped, not agent-scoped. - * - They mutate (or read) per-thread session state that lives in - * `acpSessionRegistry`. Keeping that surface separate makes the - * read-only `agents` list easier to reason about. - * - * Wire contracts (`EnsureAcpSessionRequest` / `EnsureAcpSessionResponse` - * / `AcpThreadCommandsResponse`) live in `@huabu/shared`; this route - * validates every body with `safeParse` per docs/architecture/api-design.md. - * - * Auth: relies on the global Basic-Auth gate (app.ts). No additional - * per-route check โ€” the agentlet bridge itself is gated by - * `token-store.ts`. - */ - import { acpSessionRegistry } from '@agenetes/acp-driver'; -import { AcpServiceError } from '@agenetes/acp-driver'; -import { ensureAcpSession } from '@agenetes/acp-driver'; import { getSupervisedAgentletId } from '@agenetes/agentlet-host'; import { acpPermissionDecisionSchema, - acpThreadCommandsQuerySchema, - ensureAcpSessionRequestSchema, + acpThreadCachedMetaQuerySchema, setAcpSessionConfigOptionRequestSchema, setAcpSessionModeRequestSchema, setAcpSessionModelRequestSchema, } from '@huabu/shared'; -import { ensureProfileCacheSubscription } from './profile-cache-port.js'; +import { + externalAgentRealization, + realizationHttpError, +} from './external-agent-realization.js'; import { getProfileSchemaCache } from './profile-schema-cache.js'; import { - getProfileSessionPreferences, rememberProfileConfigPreference, rememberProfileSessionPreference, } from './profile-session-preferences.js'; -import { buildReachbackEnv } from './reachback-env.js'; -import { getExternalAgentRuntimeConfig } from './runtime-config.js'; -import { resolveBindingRecipe } from './service.js'; -import { renderExternalAgentSystemPreamble } from '../../../prompt/external-agent/system-preamble.js'; import { canvasAcpNamespace } from '../../workspace/paths.js'; -import { - agenetes, - EXTERNAL_DRIVER_KIND, - type AcpWorkloadSpec, -} from '../agenetes/index.js'; +import { agenetes } from '../agenetes/index.js'; import type { AcpProfileSchemaCacheEntry } from './profile-schema-cache.js'; import type { AcpSessionEntry } from '@agenetes/acp-driver'; @@ -63,10 +30,8 @@ import type { AgentMetadata } from '@agenetes/protocol'; import type { AcpPermissionDecisionResponse, AcpSessionMetaSnapshot, - AcpThreadCommandsQuery, + AcpThreadCachedMetaQuery, AcpThreadCachedMetaResponse, - AcpThreadCommandsResponse, - EnsureAcpSessionResponse, SetAcpSessionConfigOptionResponse, SetAcpSessionModelResponse, SetAcpSessionModeResponse, @@ -85,6 +50,38 @@ function controlFailureCode(operation: string, code?: string): string { return code === 'session_suspended' ? code : `acp_${operation}_failed`; } +async function realizeControlThread( + threadId: string, + target: { + binding: { kind: 'external'; alias: string; profileId: string }; + canvasId?: string; + cwd?: string; + }, + logger: FastifyBaseLogger, +) { + try { + const realized = await externalAgentRealization.realize({ + threadId, + canvasId: target.canvasId, + requestedBinding: target.binding, + requestedCwd: target.cwd, + logger, + }); + const entry = await externalAgentRealization.ensureSession( + realized, + logger, + ); + return { ok: true as const, realized, entry }; + } catch (error) { + const failure = realizationHttpError(error); + logger.warn( + { threadId, code: failure.body.code, err: failure.body.message }, + '[acp/threads] canonical realization for set-RPC failed', + ); + return { ok: false as const, ...failure }; + } +} + function resolveThreadAgentletId(threadId: string, canvasId?: string): string { if (canvasId) { const record = agenetes.record(canvasAcpNamespace(canvasId), threadId); @@ -100,111 +97,6 @@ function resolveThreadAgentletId(threadId: string, canvasId?: string): string { return getSupervisedAgentletId(); } -/** - * Resolve the live session entry for a set-RPC (mode / model / config - * option), opening it on-demand when none exists yet. - * - * The selector dropdowns are seeded from the no-spawn `/cached-meta` - * snapshot, so the user can switch a value BEFORE the session has ever - * been spawned. Per the `/cached-meta` contract a real ensure-session - * is expected on "any set-RPC" โ€” so rather than 404 when the registry - * is cold, we spawn (or reuse) the session using the `profileId` the - * client supplies, then let the caller apply the actual switch. - * - * Returns either the resolved entry or a ready-to-send error envelope: - * โ€ข 404 `session_not_found` โ€” no live session AND no `profileId` to - * spawn with (legacy callers that didn't send spawn context). - * โ€ข 503 โ€” the on-demand spawn failed; `code` mirrors the ensure - * route's `AcpEnsureErrorCode`. - */ -async function resolveSetRpcEntry( - threadId: string, - ctx: { profileId?: string; canvasId?: string; cwd?: string }, - logger: FastifyBaseLogger, -): Promise< - | { ok: true; entry: AcpSessionEntry; spec: AcpWorkloadSpec } - | { ok: false; status: number; body: { message: string; code: string } } -> { - const agentletId = resolveThreadAgentletId(threadId, ctx.canvasId); - const existing = acpSessionRegistry.get(agentletId, threadId); - if (!ctx.profileId) { - if (existing) { - ensureProfileCacheSubscription(threadId, existing.profileId); - return { - ok: true, - entry: existing, - // A live session with no profileId in the request: rebuild the - // spec from the entry so the handle can be (re)created for the - // control op. `binding.alias` falls back to the profileId. - spec: { - threadId, - kind: EXTERNAL_DRIVER_KIND, - workloadType: 'Deployment', - namespace: existing.namespace, - spec: { - initialPreamble: [renderExternalAgentSystemPreamble()], - agentletId: existing.agentletId, - binding: { - alias: existing.profileId, - profileId: existing.profileId, - }, - cwd: existing.cwd, - recipe: existing.bindingRecipe, - }, - }, - }; - } - return { - ok: false, - status: 404, - body: { - message: 'No ACP session for this thread', - code: 'session_not_found', - }, - }; - } - const spec: AcpWorkloadSpec = { - threadId, - kind: EXTERNAL_DRIVER_KIND, - workloadType: 'Deployment', - namespace: canvasAcpNamespace(ctx.canvasId ?? ''), - spec: { - initialPreamble: [renderExternalAgentSystemPreamble()], - initialPreferences: getProfileSessionPreferences(ctx.profileId), - agentletId, - binding: { alias: ctx.profileId, profileId: ctx.profileId }, - env: buildReachbackEnv(threadId, ctx.canvasId ?? ''), - ...(ctx.cwd !== undefined && { cwd: ctx.cwd }), - recipe: resolveBindingRecipe(ctx.profileId), - }, - }; - ensureProfileCacheSubscription(threadId, ctx.profileId); - if (existing) return { ok: true, entry: existing, spec }; - try { - const entry = await ensureAcpSession({ - agentletId, - threadId: spec.threadId, - binding: spec.spec.binding, - namespace: spec.namespace, - env: spec.spec.env, - ...(spec.spec.cwd !== undefined && { cwd: spec.spec.cwd }), - recipe: spec.spec.recipe, - initialPreferences: spec.spec.initialPreferences, - idleTimeoutSecs: getExternalAgentRuntimeConfig().idleTimeoutSecs, - logger, - }); - return { ok: true, entry, spec }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const code = err instanceof AcpServiceError ? err.code : 'internal'; - logger.warn( - { threadId, code, err: message }, - '[acp/threads] on-demand ensureAcpSession for set-RPC failed', - ); - return { ok: false, status: 503, body: { message, code } }; - } -} - /** * Project the mutable session-meta fields cached on the entry into the * wire-shape clients consume. Pure; safe to call on every response. @@ -289,135 +181,15 @@ function snapshotMetaFromProfileCache( } const acpThreadsRoutes: FastifyPluginAsync = async (app) => { - /** - * Open (or reuse) the per-thread ACP session. Idempotent: repeated - * calls with the same `{threadId, profileId, canvasId}` triple - * return the same session id. Response always includes the latest - * cached `availableCommands`; an empty array means the agent has - * not yet pushed its list (caller should poll - * `/threads/:threadId/commands` after a short delay). - */ - app.post<{ - Params: ThreadParams; - Reply: EnsureAcpSessionResponse | { message: string; code?: string }; - }>('/threads/:threadId/session', async (request, reply) => { - const { threadId } = request.params; - if (!threadId || threadId.length === 0) { - return reply - .status(400) - .send({ message: 'threadId is required', code: 'bad_request' }); - } - - const parsed = ensureAcpSessionRequestSchema.safeParse(request.body); - if (!parsed.success) { - request.log.warn( - { threadId, issues: parsed.error.issues }, - '[acp/threads] invalid session request body', - ); - return reply.status(400).send({ - message: 'Invalid request body', - code: 'validation_failed', - }); - } - - try { - const agentletId = resolveThreadAgentletId( - threadId, - parsed.data.canvasId, - ); - const entry = await ensureAcpSession({ - agentletId, - threadId, - binding: { - // Alias is purely a display hint at this stage \u2014 there's no - // wire field for it on EnsureAcpSessionRequest, so we fall - // back to the profileId itself. Real callers (chat panel) - // also fetch the profile to render the picker label. - alias: parsed.data.profileId, - profileId: parsed.data.profileId, - }, - namespace: canvasAcpNamespace(parsed.data.canvasId ?? ''), - env: buildReachbackEnv(threadId, parsed.data.canvasId ?? ''), - cwd: parsed.data.cwd, - recipe: resolveBindingRecipe(parsed.data.profileId), - initialPreferences: getProfileSessionPreferences(parsed.data.profileId), - idleTimeoutSecs: getExternalAgentRuntimeConfig().idleTimeoutSecs, - logger: request.log, - }); - return { - sessionId: entry.sessionId, - availableCommands: entry.availableCommands, - updatedAt: entry.commandsUpdatedAt, - sessionMeta: snapshotSessionMeta(entry), - }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - // Surface the categorical code when the service layer threw an - // `AcpServiceError` โ€” the web client switches on it to render - // a remediation-specific tooltip / CTA. Unrecognised throws - // collapse to `'internal'` so the client can still tell them - // apart from the categorised failures. - const code = err instanceof AcpServiceError ? err.code : 'internal'; - request.log.warn( - { threadId, code, err: message }, - '[acp/threads] ensureAcpSession failed', - ); - return reply.status(503).send({ message, code }); - } - }); - - /** - * Read the cached slash-command snapshot for an existing session. - * Returns 404 when no session has been opened for `threadId` yet โ€” - * the caller should POST `/threads/:threadId/session` first. - * - * `updatedAt` is `0` when the session exists but the agent has not - * pushed `available_commands_update` yet. The web client uses this - * to decide whether to schedule a delayed re-fetch. - */ - app.get<{ - Params: ThreadParams; - Querystring: AcpThreadCommandsQuery; - Reply: AcpThreadCommandsResponse | { message: string; code?: string }; - }>('/threads/:threadId/commands', async (request, reply) => { - const { threadId } = request.params; - const parsed = acpThreadCommandsQuerySchema.safeParse(request.query); - if (!parsed.success) { - request.log.warn( - { threadId, issues: parsed.error.issues }, - '[acp/threads] invalid commands query', - ); - return reply.status(400).send({ - message: 'Invalid query', - code: 'validation_failed', - }); - } - const agentletId = resolveThreadAgentletId(threadId, parsed.data.canvasId); - const entry = acpSessionRegistry.get(agentletId, threadId); - if (!entry) { - return reply.status(404).send({ - message: 'No ACP session for this thread', - code: 'session_not_found', - }); - } - return { - sessionId: entry.sessionId, - availableCommands: entry.availableCommands, - updatedAt: entry.commandsUpdatedAt, - sessionMeta: snapshotSessionMeta(entry), - }; - }); - /** * Read-only **no-spawn** meta snapshot for a thread. * - * Unlike `POST /threads/:threadId/session`, this route NEVER - * contacts the agentlet โ€” it returns whatever the server already - * has cached, in priority order: + * This route never contacts the agentlet. It returns cached commands and + * selector metadata in priority order: * * 1. Live entry in `acpSessionRegistry` (some prior call already * opened the session this lifetime) โ†’ freshest state. - * 2. Per-thread persisted record (`session-store`) โ†’ last known + * 2. Per-thread Agenetes record โ†’ last known * state of THIS thread (includes per-thread `current*` choices * and per-session `sessionInfo` / `usage`). * 3. Per-profile schema cache (`profile-schema-cache`) โ†’ schema @@ -430,23 +202,36 @@ const acpThreadsRoutes: FastifyPluginAsync = async (app) => { * * Designed for the web's `useAcpSessionMeta` hydrate-on-mount path: * opening an existing thread can populate dropdowns from its own cache - * without paying the agentlet cold-start tax. A profile-only hit is not - * presented as current state: command Profiles ensure immediately, while - * manifest Profiles wait for their first unified turn. + * without paying the agentlet cold-start tax. A profile-only hit is + * observational: mode/model may be displayed as last observed, while + * generic config values remain unconfirmed until this thread reports or + * records an explicit selection. * * Always responds 200 โ€” absence of cache is a normal state. */ app.get<{ Params: ThreadParams; - Querystring: { canvasId?: string; profileId?: string }; - Reply: AcpThreadCachedMetaResponse; - }>('/threads/:threadId/cached-meta', async (request) => { + Querystring: AcpThreadCachedMetaQuery; + Reply: AcpThreadCachedMetaResponse | { message: string; code?: string }; + }>('/threads/:threadId/cached-meta', async (request, reply) => { const { threadId } = request.params; - const { canvasId, profileId } = request.query; + const parsed = acpThreadCachedMetaQuerySchema.safeParse(request.query); + if (!parsed.success) { + return reply.status(400).send({ + message: 'Invalid query', + code: 'validation_failed', + }); + } + const { canvasId, profileId } = parsed.data; const agentletId = resolveThreadAgentletId(threadId, canvasId); const live = acpSessionRegistry.get(agentletId, threadId); if (live) { - return { source: 'thread', sessionMeta: snapshotSessionMeta(live) }; + return { + source: 'thread', + availableCommands: live.availableCommands, + commandsUpdatedAt: live.commandsUpdatedAt, + sessionMeta: snapshotSessionMeta(live), + }; } if (canvasId) { const record = agenetes.record(canvasAcpNamespace(canvasId), threadId); @@ -454,20 +239,33 @@ const acpThreadsRoutes: FastifyPluginAsync = async (app) => { if (persistedMeta) { return { source: 'thread', + availableCommands: persistedMeta.availableCommands ?? [], + commandsUpdatedAt: persistedMeta.commandsUpdatedAt ?? 0, sessionMeta: snapshotMetaFromPersisted(persistedMeta), }; } } if (profileId) { const profileCache = getProfileSchemaCache(profileId); - if (profileCache && (profileCache.metaUpdatedAt ?? 0) > 0) { + if ( + profileCache && + ((profileCache.metaUpdatedAt ?? 0) > 0 || + (profileCache.commandsUpdatedAt ?? 0) > 0) + ) { return { source: 'profile', + availableCommands: profileCache.availableCommands ?? [], + commandsUpdatedAt: profileCache.commandsUpdatedAt ?? 0, sessionMeta: snapshotMetaFromProfileCache(profileCache), }; } } - return { source: 'none', sessionMeta: emptySessionMetaSnapshot() }; + return { + source: 'none', + availableCommands: [], + commandsUpdatedAt: 0, + sessionMeta: emptySessionMetaSnapshot(), + }; }); /** @@ -535,10 +333,14 @@ const acpThreadsRoutes: FastifyPluginAsync = async (app) => { // response is therefore best treated as "request accepted" โ€” the // authoritative state is the one carried by the next SSE event. // + // The first request realizes the complete canonical workload, ensures its + // ACP session from that same spec, and then applies the control. Later + // requests reuse the persisted workload. + // // Failure modes: - // โ€ข 404 โ€” no session for this thread (caller must POST `/session` - // first). // โ€ข 400 โ€” body failed `safeParse`. + // โ€ข 409 โ€” requested binding/cwd conflicts with the canonical thread. + // โ€ข 503 โ€” workload realization or session creation failed. // โ€ข 502 โ€” agent rejected the RPC (unknown id, capability missing, // transport error). The user-visible message comes from the // agent's rejection. @@ -559,23 +361,15 @@ const acpThreadsRoutes: FastifyPluginAsync = async (app) => { code: 'validation_failed', }); } - const resolved = await resolveSetRpcEntry( + const resolved = await realizeControlThread( threadId, - { - profileId: parsed.data.profileId, - canvasId: parsed.data.canvasId, - cwd: parsed.data.cwd, - }, + parsed.data, request.log, ); if (!resolved.ok) { return reply.status(resolved.status).send(resolved.body); } - // Fold onto the long-lived handle's control plane (M3). L1 keeps the - // spawn orchestration (resolveSetRpcEntry get-or-create with spec); the - // set-RPC goes through `handle.control()`, which resolves the same entry - // by threadId and records the selection on it before returning. - const ack = await agenetes.create(resolved.spec).control({ + const ack = await resolved.realized.handle.control({ type: 'set_mode', data: { modeId: parsed.data.modeId }, }); @@ -608,19 +402,15 @@ const acpThreadsRoutes: FastifyPluginAsync = async (app) => { code: 'validation_failed', }); } - const resolved = await resolveSetRpcEntry( + const resolved = await realizeControlThread( threadId, - { - profileId: parsed.data.profileId, - canvasId: parsed.data.canvasId, - cwd: parsed.data.cwd, - }, + parsed.data, request.log, ); if (!resolved.ok) { return reply.status(resolved.status).send(resolved.body); } - const ack = await agenetes.create(resolved.spec).control({ + const ack = await resolved.realized.handle.control({ type: 'set_model', data: { modelId: parsed.data.modelId }, }); @@ -662,19 +452,15 @@ const acpThreadsRoutes: FastifyPluginAsync = async (app) => { code: 'validation_failed', }); } - const resolved = await resolveSetRpcEntry( + const resolved = await realizeControlThread( threadId, - { - profileId: parsed.data.profileId, - canvasId: parsed.data.canvasId, - cwd: parsed.data.cwd, - }, + parsed.data, request.log, ); if (!resolved.ok) { return reply.status(resolved.status).send(resolved.body); } - const ack = await agenetes.create(resolved.spec).control({ + const ack = await resolved.realized.handle.control({ type: 'set_config_option', data: { optionId: parsed.data.configOptionId, diff --git a/apps/server/src/modules/agent/agenetes/drivers.ts b/apps/server/src/modules/agent/agenetes/drivers.ts index e514dffcc..28c49c418 100644 --- a/apps/server/src/modules/agent/agenetes/drivers.ts +++ b/apps/server/src/modules/agent/agenetes/drivers.ts @@ -21,7 +21,7 @@ import { HISTORY_LOAD_SANITY_LIMIT } from './history-replay.js'; import { huabuPiDriverPorts } from './pi-driver.js'; import { getExternalAgentRuntimeConfig } from '../acp/runtime-config.js'; -import type { AcpSpec } from '@agenetes/acp-driver'; +import type { AcpRuntimePolicy, AcpSpec } from '@agenetes/acp-driver'; import type { Agenetes } from '@agenetes/agenetes'; import type { PiWorkloadSpec } from '@agenetes/pi-driver'; import type { AgentHandle as RuntimeAgentHandle } from '@agenetes/runtime'; @@ -36,7 +36,7 @@ export type AcpHandle = AgentHandle; export type BuiltinHandle = AgentHandle; export type AgenetesHandle = RuntimeAgentHandle; -const externalDriver = acpDriverFactory({ +export const acpRuntimePolicy: AcpRuntimePolicy = { getIdleTimeoutSecs: () => getExternalAgentRuntimeConfig().idleTimeoutSecs, resolveRuntimeEnvironment: async (spec: AcpSpec) => { const agentTeam = spec.recipe?.agentTeam; @@ -55,7 +55,9 @@ const externalDriver = acpDriverFactory({ }); return runtime.environment; }, -}); +}; + +const externalDriver = acpDriverFactory(acpRuntimePolicy); export const agenetes: Agenetes = mountAgenetes({ drivers: { diff --git a/apps/server/src/modules/agent/agent-thread.service.test.ts b/apps/server/src/modules/agent/agent-thread.service.test.ts index 00ee0a7cf..4c5415573 100644 --- a/apps/server/src/modules/agent/agent-thread.service.test.ts +++ b/apps/server/src/modules/agent/agent-thread.service.test.ts @@ -15,6 +15,7 @@ import { spacePromptFromWorkloadSpec, } from './agent-thread.service.js'; +import type { AcpHandle, AcpWorkloadSpec } from './agenetes/drivers.js'; import type { FixedAgentNodeTarget } from './agent-thread-resolver.js'; import type { runAgent } from './agent.service.js'; import type { ChatEnvelope } from './conversation/envelope.js'; @@ -119,6 +120,27 @@ function createHarness(options?: { truncated: false, }, }); + const realizeExternal = vi.fn( + async ({ + requestedBinding, + fixedTarget, + }: { + requestedBinding?: Extract; + fixedTarget?: FixedAgentNodeTarget | null; + }) => { + const binding = + fixedTarget?.agentBinding.kind === 'external' + ? fixedTarget.agentBinding + : requestedBinding; + if (!binding) throw new Error('Missing external binding'); + return { + binding, + fixedTarget: fixedTarget ?? null, + spec: {} as AcpWorkloadSpec, + handle: {} as AcpHandle, + }; + }, + ); const service = new AgentThreadService({ resolveFixedAgentNode: async () => options && 'target' in options ? (options.target ?? null) : TARGET, @@ -129,6 +151,7 @@ function createHarness(options?: { resolvePersistedSpacePrompt: () => options?.persistedSpacePrompt ?? { realised: false }, collectSpacePrompt, + realizeExternal, waitForTurnRelease: vi.fn().mockResolvedValue(undefined), acquireTurn: vi.fn(() => (options?.busy ? null : release)), startLifecycle, @@ -147,6 +170,7 @@ function createHarness(options?: { runExternal, runInternal, collectSpacePrompt, + realizeExternal, }; } @@ -183,7 +207,7 @@ describe('AgentThreadService', () => { expect(externalBindingFromWorkloadSpec({ binding: {} })).toBeNull(); }); - it('reads internal and external Space Prompt snapshots from workload specs', () => { + it('reads built-in Space Prompt snapshots without inferring from ACP preambles', () => { expect( spacePromptFromWorkloadSpec({ hostContext: { @@ -199,7 +223,7 @@ describe('AgentThreadService', () => { 'Node constraints', ], }), - ).toBe('External'); + ).toBeUndefined(); expect( spacePromptFromWorkloadSpec({ initialPreamble: ['Bootstrap'] }), ).toBeUndefined(); @@ -249,9 +273,8 @@ describe('AgentThreadService', () => { expect(emitted.map((event) => event.type)).toEqual(['text_delta', 'done']); expect(harness.runExternal).toHaveBeenCalledWith( expect.objectContaining({ + handle: expect.any(Object), binding: TARGET.agentBinding, - launchOverrides: TARGET.launchOverrides, - spacePrompt: 'Space prompt', }), ); expect(harness.finishLifecycle).toHaveBeenCalledWith(TARGET); @@ -330,27 +353,6 @@ describe('AgentThreadService', () => { ); }); - it('reuses a realised Agent Node prompt snapshot without recollecting', async () => { - const harness = createHarness({ - persistedSpacePrompt: { - realised: true, - markdown: 'Original rules', - }, - }); - const invocation = await harness.service.invoke(invocationOptions()); - - for await (const _event of invocation.events) { - // Drain the canonical invocation stream. - } - - expect(harness.collectSpacePrompt).not.toHaveBeenCalled(); - expect(harness.runExternal).toHaveBeenCalledWith( - expect.objectContaining({ - spacePrompt: 'Original rules', - }), - ); - }); - it('does not collect a Space Prompt for a non-fixed thread', async () => { const harness = createHarness({ target: null }); const invocation = await harness.service.invoke({ @@ -363,9 +365,6 @@ describe('AgentThreadService', () => { } expect(harness.collectSpacePrompt).not.toHaveBeenCalled(); - expect(harness.runExternal).toHaveBeenCalledWith( - expect.objectContaining({ spacePrompt: undefined }), - ); }); it('does not dispatch when the start lifecycle patch fails', async () => { @@ -441,6 +440,12 @@ describe('AgentThreadService', () => { resolvePersistedExternalBinding: () => null, resolvePersistedSpacePrompt: () => ({ realised: false }), collectSpacePrompt: vi.fn().mockResolvedValue(undefined), + realizeExternal: vi.fn().mockResolvedValue({ + binding: TARGET.agentBinding, + fixedTarget: TARGET, + spec: {} as AcpWorkloadSpec, + handle: {} as AcpHandle, + }), waitForTurnRelease: vi.fn().mockResolvedValue(undefined), acquireTurn: vi.fn(() => vi.fn()), startLifecycle, diff --git a/apps/server/src/modules/agent/agent-thread.service.ts b/apps/server/src/modules/agent/agent-thread.service.ts index 61158d39c..2fd903b79 100644 --- a/apps/server/src/modules/agent/agent-thread.service.ts +++ b/apps/server/src/modules/agent/agent-thread.service.ts @@ -5,6 +5,7 @@ import { emptyAcpOverlay } from '@agenetes/acp-driver'; import { AGENT_SSE_EVENTS, agentBindingSchema } from '@huabu/shared'; +import { externalAgentRealization } from './acp/external-agent-realization.js'; import { runAcpAgent } from './acp/service.js'; import { agenetes, EXTERNAL_DRIVER_KIND } from './agenetes/drivers.js'; import { agentNodeLifecycle } from './agent-node-lifecycle.js'; @@ -21,6 +22,10 @@ import { acquireAgentTurn, waitForAgentTurnRelease } from './turn-lease.js'; import { loadAgent } from '../../prompt/index.js'; import { canvasAcpNamespace } from '../workspace/paths.js'; +import type { + RealizedExternalAgentThread, + RealizeExternalAgentThreadOptions, +} from './acp/external-agent-realization.js'; import type { HuabuSubmission } from './agenetes/handle.js'; import type { ChatEnvelope } from './conversation/envelope.js'; import type { RenderedSpacePrompt } from './space-instruction-frames.js'; @@ -46,6 +51,9 @@ interface AgentThreadServiceDependencies { threadId: string, ) => { realised: boolean; markdown?: string }; collectSpacePrompt: (canvasId: string) => Promise; + realizeExternal: ( + options: RealizeExternalAgentThreadOptions, + ) => Promise; waitForTurnRelease: typeof waitForAgentTurnRelease; acquireTurn: typeof acquireAgentTurn; startLifecycle: typeof agentNodeLifecycle.start; @@ -77,12 +85,7 @@ export function spacePromptFromWorkloadSpec(spec: unknown): string | undefined { const prompt = (hostContext as Record).spacePrompt; if (typeof prompt === 'string') return prompt; } - const preamble = value.initialPreamble; - if (!Array.isArray(preamble)) return undefined; - return preamble.find( - (entry): entry is string => - typeof entry === 'string' && entry.startsWith(''), - ); + return undefined; } const DEFAULT_DEPENDENCIES: AgentThreadServiceDependencies = { @@ -100,6 +103,7 @@ const DEFAULT_DEPENDENCIES: AgentThreadServiceDependencies = { return markdown ? { realised: true, markdown } : { realised: true }; }, collectSpacePrompt: resolveSpacePrompt, + realizeExternal: (options) => externalAgentRealization.realize(options), waitForTurnRelease: waitForAgentTurnRelease, acquireTurn: acquireAgentTurn, startLifecycle: agentNodeLifecycle.start.bind(agentNodeLifecycle), @@ -142,7 +146,11 @@ export interface AgentThreadInvocationOptions { type EffectiveAgentThreadInvocationOptions = Omit< AgentThreadInvocationOptions, 'signal' -> & { signal: AbortSignal; spacePrompt?: string }; +> & { + signal: AbortSignal; + spacePrompt?: string; + externalRealization?: RealizedExternalAgentThread; +}; export interface AgentThreadInvocation { binding: AgentBinding; @@ -230,8 +238,30 @@ export class AgentThreadService { options.fixedTarget === undefined ? await this.resolveFixedTarget(options.canvasId, options.threadId) : options.fixedTarget; - const binding: AgentBinding = fixedTarget?.agentBinding ?? + const persistedExternalBinding = + !fixedTarget && options.canvasId + ? this.dependencies.resolvePersistedExternalBinding( + options.canvasId, + options.threadId, + ) + : null; + let binding: AgentBinding = fixedTarget?.agentBinding ?? + persistedExternalBinding ?? options.requestBinding ?? { kind: 'internal' }; + const externalRealization = + binding.kind === 'external' + ? await this.dependencies.realizeExternal({ + threadId: options.threadId, + canvasId: options.canvasId, + requestedBinding: + options.requestBinding?.kind === 'external' + ? options.requestBinding + : undefined, + fixedTarget, + logger: options.logger, + }) + : undefined; + if (externalRealization) binding = externalRealization.binding; await this.dependencies.waitForTurnRelease(options.threadId); const releaseTurn = this.dependencies.acquireTurn(options.threadId); @@ -255,7 +285,7 @@ export class AgentThreadService { let spacePrompt: string | undefined; try { - if (fixedTarget && options.canvasId) { + if (fixedTarget && options.canvasId && binding.kind !== 'external') { const persisted = this.dependencies.resolvePersistedSpacePrompt( options.canvasId, options.threadId, @@ -301,6 +331,7 @@ export class AgentThreadService { ...options, signal, spacePrompt, + externalRealization, }; let settled = false; @@ -423,17 +454,19 @@ export class AgentThreadService { onTurnStarted: () => void, ): AsyncGenerator { if (binding.kind === 'external') { + if (!options.externalRealization) { + throw new Error( + `External thread ${options.threadId} was not canonically realized`, + ); + } return this.dependencies.runExternal({ + handle: options.externalRealization.handle, binding, threadId: options.threadId, canvasId: options.canvasId, envelope: options.envelope, submission: options.submission, overlay: emptyAcpOverlay(), - ...(fixedTarget?.launchOverrides - ? { launchOverrides: fixedTarget.launchOverrides } - : {}), - spacePrompt: options.spacePrompt, signal: options.signal, logger: options.logger, debugPrompt: options.debugPrompt, diff --git a/apps/server/src/modules/agent/agent.route.ts b/apps/server/src/modules/agent/agent.route.ts index 1ce5a34e2..5fea14ef8 100644 --- a/apps/server/src/modules/agent/agent.route.ts +++ b/apps/server/src/modules/agent/agent.route.ts @@ -22,8 +22,8 @@ import { setChatThreadReasoningEffortRequestSchema, } from '@huabu/shared'; -import { agenetes } from '../agent/agenetes/drivers.js'; -import { INTERNAL_DRIVER_KIND } from '../agent/agenetes/drivers.js'; +import { ExternalAgentRealizationError } from '../agent/acp/external-agent-realization.js'; +import { agenetes, INTERNAL_DRIVER_KIND } from '../agent/agenetes/drivers.js'; import { AgentThreadBusyError, agentThreadService, @@ -621,6 +621,12 @@ const agentRoutes: FastifyPluginAsync = async ( code: 'thread_busy', }); } + if (error instanceof ExternalAgentRealizationError) { + return reply.code(409).send({ + message: error.message, + code: error.code, + }); + } throw error; } diff --git a/apps/web/src/api/_routes.ts b/apps/web/src/api/_routes.ts index 972dd41d1..3b1d34e50 100644 --- a/apps/web/src/api/_routes.ts +++ b/apps/web/src/api/_routes.ts @@ -140,12 +140,6 @@ export const routes = { acpAgentlet: '/acp/agentlet', acpAgentletRestart: '/acp/agentlet/restart', acpRuntimeConfig: '/acp/runtime-config', - acpThreadSession: (threadId: string) => - `/acp/threads/${enc(threadId)}/session`, - acpThreadCommands: (threadId: string, canvasId?: string) => { - const params = canvasId ? `?canvasId=${enc(canvasId)}` : ''; - return `/acp/threads/${enc(threadId)}/commands${params}`; - }, acpThreadCachedMeta: ( threadId: string, canvasId?: string, diff --git a/apps/web/src/api/acp.ts b/apps/web/src/api/acp.ts index b101bae70..96d058897 100644 --- a/apps/web/src/api/acp.ts +++ b/apps/web/src/api/acp.ts @@ -9,7 +9,7 @@ * โ€” instead they author **profiles** ({@link AcpAgentProfile}) which * describe how to spawn one external agent CLI on demand. This module * wraps the loopback-only profile/daemon endpoints plus the existing - * thread-scoped session / commands routes. + * thread-scoped cached capability and control routes. * * Endpoint surface: * - `GET /api/acp/agent-cli` โ€” probe the trusted built-in agent catalogue @@ -18,11 +18,11 @@ * recipes. Always returns the runtime status (spawned/pid/etc.) * alongside each profile. * - `GET/POST /api/acp/daemon` โ€” daemon liveness + manual restart. - * - `POST /api/acp/threads/:threadId/session` etc. โ€” thread-scoped - * session lifecycle and per-session config knobs. + * - `GET /api/acp/threads/:threadId/cached-meta` โ€” cached capabilities. + * - thread control POSTs โ€” canonical realization plus per-session knobs. */ -import { ApiError, apiFetch } from './_client'; +import { apiFetch } from './_client'; import { routes } from './_routes'; import type { @@ -36,9 +36,6 @@ import type { CreateAcpCommandProfileBody, PatchAgentProfileBody, AcpThreadCachedMetaResponse, - AcpThreadCommandsResponse, - EnsureAcpSessionRequest, - EnsureAcpSessionResponse, SetAcpSessionConfigOptionRequest, SetAcpSessionConfigOptionResponse, SetAcpSessionModelRequest, @@ -64,10 +61,7 @@ export type { AcpSessionMetaSnapshot, AcpSessionMode, AcpThreadCachedMetaResponse, - AcpThreadCommandsResponse, AvailableCommand, - EnsureAcpSessionRequest, - EnsureAcpSessionResponse, SetAcpSessionConfigOptionRequest, SetAcpSessionConfigOptionResponse, SetAcpSessionModelRequest, @@ -184,60 +178,9 @@ export async function updateExternalAgentRuntimeConfig( }); } -// โ”€โ”€ Per-thread session lifecycle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - /** - * Eagerly open (or reuse) the per-thread ACP session so the slash-command - * typeahead can pull commands BEFORE the user submits their first prompt. - * Idempotent: calling repeatedly with the same `{threadId, profileId, - * canvasId}` triple is a no-op server-side. - * - * Response always carries the latest `availableCommands`; an empty array - * means the agent has not yet pushed its list โ€” callers should follow up - * with {@link getAcpThreadCommands} after a short delay to catch late pushes. - */ -export async function ensureAcpSession( - threadId: string, - payload: EnsureAcpSessionRequest, -): Promise { - return apiFetch(routes.acpThreadSession(threadId), { - method: 'POST', - json: payload, - fallbackMessage: 'Failed to open ACP session', - }); -} - -/** - * Read the cached slash-command snapshot for an existing session. - * Returns `null` when the server has no session for this thread yet - * (404) so callers can ignore the missing-session case without - * branching on `ApiError.status`. - */ -export async function getAcpThreadCommands( - threadId: string, - canvasId?: string, -): Promise { - try { - return await apiFetch( - routes.acpThreadCommands(threadId, canvasId), - { fallbackMessage: 'Failed to fetch ACP slash commands' }, - ); - } catch (err) { - if (err instanceof ApiError && err.status === 404) return null; - throw err; - } -} - -/** - * Fetch the server-cached session-meta snapshot WITHOUT spawning the - * agentlet. Always resolves to a snapshot (possibly the empty - * `updatedAt === 0` form) โ€” the server returns 200 even on cache - * miss so the UI can render an optimistic neutral state. - * - * Used by `useAcpSessionMeta` on mount to populate selector dropdowns - * (model / mode / config options) from the last-known state of the - * thread, so the user can pre-select a model before sending the first - * message without paying the cold-start tax of `ensureAcpSession`. + * Fetch the GET-only capability observation for a thread and its Profile. + * This never creates a workload or starts an ACP process. */ export async function getAcpThreadCachedMeta( threadId: string, diff --git a/apps/web/src/components/Panels/ChatPanel/AcpConnectionBadge.tsx b/apps/web/src/components/Panels/ChatPanel/AcpConnectionBadge.tsx index d9c5b8fb1..961a46793 100644 --- a/apps/web/src/components/Panels/ChatPanel/AcpConnectionBadge.tsx +++ b/apps/web/src/components/Panels/ChatPanel/AcpConnectionBadge.tsx @@ -14,21 +14,14 @@ * States (mutually exclusive; derived upstream from * {@link useAcpSessionMeta}'s `{loading, error, meta.updatedAt}`): * - * โ€ข `connecting` โ€” a real `ensureAcpSession` is currently in flight - * (refresh / set-mode / set-model / set-config-option). Blue - * breathing dot, no text โ€” the warm-up usually completes within - * a few hundred ms. + * โ€ข `connecting` โ€” the GET-only capability cache read is in flight. * * โ€ข `connected` โ€” default. Cache hit, post-success steady state, * OR a transient refresh error while we still have a usable * cached snapshot. Green solid dot, no text โ€” once everything is * working the badge should be near-invisible chrome. * - * โ€ข `failed` โ€” the last ensure rejected AND there's no cached - * snapshot to fall back on. Red dot + uppercase "FAILED" text so - * the failure is unmissable. Tooltip carries the actual error - * message when available, falling back to a generic explanation - * pointing at Settings โ†’ External Agents. + * โ€ข `failed` โ€” the cache read failed and there is no snapshot to show. * * The component never renders for internal bindings or before the * upstream status enum has been derived โ€” the parent gates on @@ -39,7 +32,6 @@ import { useTranslation } from 'react-i18next'; import { Tooltip } from '@/components/Common/Tooltip'; -import type { AcpEnsureErrorCode } from '@huabu/shared'; import type { FC } from 'react'; export type AcpConnectionStatus = 'connecting' | 'connected' | 'failed'; @@ -49,24 +41,15 @@ interface AcpConnectionBadgeProps { /** Display name of the bound external agent โ€” shown in tooltips. */ alias: string; /** - * Last error from the ensure-session pipeline. Used as the tooltip - * body for the `failed` state. Ignored for other states. + * Last capability-cache read error. Used by the failed-state tooltip. */ errorMessage?: string | null; - /** - * Categorical error code from the server (when available). Drives - * a remediation-specific tooltip headline so the user knows the - * concrete next step (e.g. "Restart worker" vs "Re-create profile") - * instead of just seeing a raw error message. - */ - errorCode?: AcpEnsureErrorCode | null; } export const AcpConnectionBadge: FC = ({ status, alias, errorMessage, - errorCode, }) => { const { t } = useTranslation(); if (status === 'connecting') { @@ -106,7 +89,7 @@ export const AcpConnectionBadge: FC = ({ // without needing to read the raw error. The detail message is // appended on a second line so power users can still see the // underlying server text. - const headline = headlineForCode(errorCode, alias, t); + const headline = t('chat.connectionHeadline.fallback', { alias }); const tooltipText = errorMessage && errorMessage.length > 0 ? `${headline}\n\n${errorMessage}` @@ -125,65 +108,8 @@ export const AcpConnectionBadge: FC = ({ aria-hidden className="bg-danger h-1.5 w-1.5 shrink-0 rounded-full" /> - {labelForCode(errorCode, t)} + {t('chat.connectionLabel.failed')} ); }; - -/** - * Short uppercase label rendered next to the red dot. Kept terse - * (โ‰ค7 chars) so it doesn't blow out the toolbar; the full sentence - * lives in the tooltip. - */ -function labelForCode( - code: AcpEnsureErrorCode | null | undefined, - t: ReturnType['t'], -): string { - switch (code) { - case 'worker_not_ready': - case 'placement_unavailable': - return t('chat.connectionLabel.worker'); - case 'profile_missing': - return t('chat.connectionLabel.profile'); - case 'spawn_failed': - case 'session_resume_unavailable': - return t('chat.connectionLabel.spawn'); - case 'connect_timeout': - return t('chat.connectionLabel.timeout'); - case 'bridge_not_mounted': - return t('chat.connectionLabel.starting'); - default: - return t('chat.connectionLabel.failed'); - } -} - -/** - * One-sentence remediation headline shown at the top of the tooltip. - * Each code points at the concrete next step โ€” the raw server - * message is appended below for diagnostics. - */ -function headlineForCode( - code: AcpEnsureErrorCode | null | undefined, - alias: string, - t: ReturnType['t'], -): string { - switch (code) { - case 'worker_not_ready': - case 'placement_unavailable': - return t('chat.connectionHeadline.workerNotReady'); - case 'profile_missing': - return t('chat.connectionHeadline.profileMissing', { alias }); - case 'spawn_failed': - case 'session_resume_unavailable': - return t('chat.connectionHeadline.spawnFailed', { alias }); - case 'connect_timeout': - return t('chat.connectionHeadline.connectTimeout', { alias }); - case 'bridge_not_mounted': - return t('chat.connectionHeadline.bridgeNotMounted'); - case 'internal': - return t('chat.connectionHeadline.internal', { alias }); - default: - return t('chat.connectionHeadline.fallback', { alias }); - } -} diff --git a/apps/web/src/components/Panels/ChatPanel/AcpSessionSelectors.test.tsx b/apps/web/src/components/Panels/ChatPanel/AcpSessionSelectors.test.tsx index 4f51888d4..4f428f9a4 100644 --- a/apps/web/src/components/Panels/ChatPanel/AcpSessionSelectors.test.tsx +++ b/apps/web/src/components/Panels/ChatPanel/AcpSessionSelectors.test.tsx @@ -44,17 +44,20 @@ vi.mock('../../Common/Select', () => ({ value, onChange, title, + placeholder, }: { options: Array<{ value: string; label: string }>; value: string; onChange: (value: string) => void; title?: string; + placeholder?: string; }) => (