From 4a9a14751db510088b7403334aa8f699b2a5023a Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 14 Sep 2026 01:36:44 +0200 Subject: [PATCH 1/6] fix(agent): preserve bound identity in hosted tool calls --- docs/guides/agent-service-runtime.md | 3 +++ src/agent/hosted/default-chat-runtime.test.ts | 6 +++++- src/agent/hosted/default-chat-runtime.ts | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/guides/agent-service-runtime.md b/docs/guides/agent-service-runtime.md index 7c79c3349e..77c9ab17a5 100644 --- a/docs/guides/agent-service-runtime.md +++ b/docs/guides/agent-service-runtime.md @@ -87,6 +87,9 @@ When exactly one code or markdown agent is discovered, that agent becomes the default for direct `/api/runs` requests. Pass `agentId` when the service exposes multiple agents and direct requests need a predictable default. +Hosted tool execution preserves the selected agent ID and durable run ID. Remote +tool calls carry these identities so they match the run authorization claims. + ## Keep agent behavior in project files Define the agent in `agents/` and keep service startup separate from agent diff --git a/src/agent/hosted/default-chat-runtime.test.ts b/src/agent/hosted/default-chat-runtime.test.ts index 0412dcb0f3..e7bf8b9779 100644 --- a/src/agent/hosted/default-chat-runtime.test.ts +++ b/src/agent/hosted/default-chat-runtime.test.ts @@ -527,7 +527,7 @@ Deno.test("createDefaultHostedChatRuntime builds a cloud-backed hosted runtime", assertEquals(capturedContext.availableToolNames, ["sleep"]); }); -Deno.test("createDefaultHostedChatRuntime forwards project identity to tool execution", async () => { +Deno.test("createDefaultHostedChatRuntime forwards bound run and agent identity to tool execution", async () => { await runWithProjectRequestContext( { projectId: "project-1", @@ -584,6 +584,8 @@ Deno.test("createDefaultHostedChatRuntime forwards project identity to tool exec authToken: "token-1", instructions: "Inspect the runtime context.", model: "test/hosted-context", + runId: "run-bound-default-chat", + agentId: "veryfront", allowedTools: ["inspect_context"], }, config: { @@ -618,6 +620,8 @@ Deno.test("createDefaultHostedChatRuntime forwards project identity to tool exec assertEquals(capturedExecutionContext?.projectId, "project-1"); assertEquals(capturedExecutionContext?.projectSlug, "project-slug-1"); + assertEquals(capturedExecutionContext?.runId, "run-bound-default-chat"); + assertEquals(capturedExecutionContext?.agentId, "veryfront"); } finally { clearModelProviders(); } diff --git a/src/agent/hosted/default-chat-runtime.ts b/src/agent/hosted/default-chat-runtime.ts index 6a83285c6f..081e3b87a6 100644 --- a/src/agent/hosted/default-chat-runtime.ts +++ b/src/agent/hosted/default-chat-runtime.ts @@ -335,7 +335,7 @@ function createRuntimeAgentConfig(input: PreparedHostedRuntimeAgentOptions): Age refreshSystem, }); const runtimeConfig: RuntimeToolFilterConfig = { - id: input.runtimeAgentId ?? "veryfront-hosted-runtime", + id: input.runtimeAgentId ?? input.options.agentId ?? "veryfront-hosted-runtime", model: input.modelId, system: input.toolAssembly.systemMessages ?? input.toolAssembly.systemInstructions, tools: runtimeTools, From 99a15129d3ff44cb4253067e567a56e49fb67f62 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 14 Sep 2026 02:43:30 +0200 Subject: [PATCH 2/6] fix(agent): retain hosted fallback for blank optional identities --- src/agent/hosted/default-chat-runtime.test.ts | 21 +++++++++++++++++++ src/agent/hosted/default-chat-runtime.ts | 3 ++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/agent/hosted/default-chat-runtime.test.ts b/src/agent/hosted/default-chat-runtime.test.ts index e7bf8b9779..8c316bd9c7 100644 --- a/src/agent/hosted/default-chat-runtime.test.ts +++ b/src/agent/hosted/default-chat-runtime.test.ts @@ -527,6 +527,27 @@ Deno.test("createDefaultHostedChatRuntime builds a cloud-backed hosted runtime", assertEquals(capturedContext.availableToolNames, ["sleep"]); }); +it("keeps the hosted runtime usable when an optional agent identity is blank", async () => { + for (const agentId of ["", " "]) { + const runtime = await createDefaultHostedChatRuntime({ + sourceIntegrationPolicy: denyAllSourceIntegrationPolicy, + options: { + projectId: "project-1", + authToken: "fixture-token", + instructions: "Respond briefly.", + model: "openai/gpt-5.4", + agentId, + }, + config: { apiUrl: "https://api.example.com", apiMcpUrl: "https://api.example.com/mcp" }, + buildLocalTools: () => ({}), + createRemoteToolSource: emptyRemoteSource, + preloadLatestConversationUserText: false, + }); + assertExists(runtime.agent); + await runtime.cleanup(); + } +}); + Deno.test("createDefaultHostedChatRuntime forwards bound run and agent identity to tool execution", async () => { await runWithProjectRequestContext( { diff --git a/src/agent/hosted/default-chat-runtime.ts b/src/agent/hosted/default-chat-runtime.ts index 081e3b87a6..f23fe74a7b 100644 --- a/src/agent/hosted/default-chat-runtime.ts +++ b/src/agent/hosted/default-chat-runtime.ts @@ -335,7 +335,8 @@ function createRuntimeAgentConfig(input: PreparedHostedRuntimeAgentOptions): Age refreshSystem, }); const runtimeConfig: RuntimeToolFilterConfig = { - id: input.runtimeAgentId ?? input.options.agentId ?? "veryfront-hosted-runtime", + id: input.runtimeAgentId ?? + (input.options.agentId?.trim() ? input.options.agentId : "veryfront-hosted-runtime"), model: input.modelId, system: input.toolAssembly.systemMessages ?? input.toolAssembly.systemInstructions, tools: runtimeTools, From 55514ff3e634de093abbe11c0e9911b396ea221f Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 14 Sep 2026 08:06:24 +0200 Subject: [PATCH 3/6] fix(agent): honor the resolved hosted task-context identity --- docs/guides/agent-service-runtime.md | 2 + src/agent/hosted/default-chat-runtime.test.ts | 208 ++++++++++-------- src/agent/hosted/default-chat-runtime.ts | 3 + 3 files changed, 121 insertions(+), 92 deletions(-) diff --git a/docs/guides/agent-service-runtime.md b/docs/guides/agent-service-runtime.md index 77c9ab17a5..31e1d2f5e2 100644 --- a/docs/guides/agent-service-runtime.md +++ b/docs/guides/agent-service-runtime.md @@ -89,6 +89,8 @@ multiple agents and direct requests need a predictable default. Hosted tool execution preserves the selected agent ID and durable run ID. Remote tool calls carry these identities so they match the run authorization claims. +If you customize `createTaskContext`, return the resolved `agentId` in that context. +Hosted execution uses that identity, including any normalization your hook applies. ## Keep agent behavior in project files diff --git a/src/agent/hosted/default-chat-runtime.test.ts b/src/agent/hosted/default-chat-runtime.test.ts index 8c316bd9c7..7e0e4d417d 100644 --- a/src/agent/hosted/default-chat-runtime.test.ts +++ b/src/agent/hosted/default-chat-runtime.test.ts @@ -548,107 +548,131 @@ it("keeps the hosted runtime usable when an optional agent identity is blank", a } }); -Deno.test("createDefaultHostedChatRuntime forwards bound run and agent identity to tool execution", async () => { - await runWithProjectRequestContext( +for ( + const identityCase of [ + { name: "default", expected: "veryfront", resolved: undefined }, + { name: "normalized task context", expected: "canonical-agent", resolved: "canonical-agent" }, { - projectId: "project-1", - projectSlug: "project-slug-1", - token: "token-1", + name: "removed task context identity", + expected: "veryfront-hosted-runtime", + resolved: undefined, }, - async () => { - clearModelProviders(); - let modelCallCount = 0; - let capturedExecutionContext: ToolExecutionContext | undefined; + { name: "blank task context identity", expected: "veryfront-hosted-runtime", resolved: " " }, + ] +) { + Deno.test(`createDefaultHostedChatRuntime forwards bound identity from ${identityCase.name}`, async () => { + await runWithProjectRequestContext( + { + projectId: "project-1", + projectSlug: "project-slug-1", + token: "token-1", + }, + async () => { + clearModelProviders(); + let modelCallCount = 0; + let capturedExecutionContext: ToolExecutionContext | undefined; - registerModelProvider("test", () => ({ - provider: "test", - modelId: "test/hosted-context", - doGenerate: () => Promise.reject(new Error("unused")), - doStream() { - modelCallCount += 1; - return Promise.resolve({ - stream: new ReadableStream({ - start(controller) { - if (modelCallCount === 1) { - controller.enqueue({ - type: "tool-call", - toolCallId: "inspect-context-1", - toolName: "inspect_context", - input: {}, - }); - controller.enqueue({ - type: "finish", - finishReason: "tool-calls", - usage: { inputTokens: 1, outputTokens: 1 }, - }); - } else { - controller.enqueue({ type: "text-delta", text: "done" }); - controller.enqueue({ - type: "finish", - finishReason: "stop", - usage: { inputTokens: 1, outputTokens: 1 }, - }); - } - controller.close(); + registerModelProvider("test", () => ({ + provider: "test", + modelId: "test/hosted-context", + doGenerate: () => Promise.reject(new Error("unused")), + doStream() { + modelCallCount += 1; + return Promise.resolve({ + stream: new ReadableStream({ + start(controller) { + if (modelCallCount === 1) { + controller.enqueue({ + type: "tool-call", + toolCallId: "inspect-context-1", + toolName: "inspect_context", + input: {}, + }); + controller.enqueue({ + type: "finish", + finishReason: "tool-calls", + usage: { inputTokens: 1, outputTokens: 1 }, + }); + } else { + controller.enqueue({ type: "text-delta", text: "done" }); + controller.enqueue({ + type: "finish", + finishReason: "stop", + usage: { inputTokens: 1, outputTokens: 1 }, + }); + } + controller.close(); + }, + }), + }); + }, + })); + + try { + const runtime = await createDefaultHostedChatRuntime({ + sourceIntegrationPolicy: denyAllSourceIntegrationPolicy, + options: { + projectId: "project-1", + projectSlug: "project-slug-1", + authToken: "token-1", + instructions: "Inspect the runtime context.", + model: "test/hosted-context", + runId: "run-bound-default-chat", + agentId: "veryfront", + allowedTools: ["inspect_context"], + }, + config: { + apiUrl: "https://api.example.com", + apiMcpUrl: "https://api.example.com/mcp", + }, + ...(identityCase.name === "default" ? {} : { + createTaskContext: ({ options, modelId }) => ({ + authToken: options.authToken, + runId: options.runId, + agentId: identityCase.resolved, + projectId: options.projectId ?? "", + projectSlug: options.projectSlug, + branchId: options.branchId ?? null, + model: modelId, + }), + }), + buildLocalTools: () => ({ + inspect_context: { + ...localTool("Inspect the runtime context"), + execute: (_input: unknown, context?: ToolExecutionContext) => { + capturedExecutionContext = context; + return { ok: true }; + }, }, }), + createRemoteToolSource: emptyRemoteSource, + preloadLatestConversationUserText: false, }); - }, - })); - try { - const runtime = await createDefaultHostedChatRuntime({ - sourceIntegrationPolicy: denyAllSourceIntegrationPolicy, - options: { - projectId: "project-1", - projectSlug: "project-slug-1", - authToken: "token-1", - instructions: "Inspect the runtime context.", - model: "test/hosted-context", - runId: "run-bound-default-chat", - agentId: "veryfront", - allowedTools: ["inspect_context"], - }, - config: { - apiUrl: "https://api.example.com", - apiMcpUrl: "https://api.example.com/mcp", - }, - buildLocalTools: () => ({ - inspect_context: { - ...localTool("Inspect the runtime context"), - execute: (_input: unknown, context?: ToolExecutionContext) => { - capturedExecutionContext = context; - return { ok: true }; - }, + await withMockFetch( + () => Promise.resolve(Response.json({ tools: [] })), + async () => { + const result = await runtime.agent.stream({ + messages: [], + abortSignal: new AbortController().signal, + }); + for await (const _chunk of result.toUIMessageStream()) { + // Consume the complete tool-call round trip. + } }, - }), - createRemoteToolSource: emptyRemoteSource, - preloadLatestConversationUserText: false, - }); + ); - await withMockFetch( - () => Promise.resolve(Response.json({ tools: [] })), - async () => { - const result = await runtime.agent.stream({ - messages: [], - abortSignal: new AbortController().signal, - }); - for await (const _chunk of result.toUIMessageStream()) { - // Consume the complete tool-call round trip. - } - }, - ); - - assertEquals(capturedExecutionContext?.projectId, "project-1"); - assertEquals(capturedExecutionContext?.projectSlug, "project-slug-1"); - assertEquals(capturedExecutionContext?.runId, "run-bound-default-chat"); - assertEquals(capturedExecutionContext?.agentId, "veryfront"); - } finally { - clearModelProviders(); - } - }, - ); -}); + assertEquals(capturedExecutionContext?.projectId, "project-1"); + assertEquals(capturedExecutionContext?.projectSlug, "project-slug-1"); + assertEquals(capturedExecutionContext?.runId, "run-bound-default-chat"); + assertEquals(capturedExecutionContext?.agentId, identityCase.expected); + } finally { + clearModelProviders(); + } + }, + ); + }); +} Deno.test("createDefaultHostedChatRuntime keeps hosted credentials out of project tools", async () => { clearModelProviders(); diff --git a/src/agent/hosted/default-chat-runtime.ts b/src/agent/hosted/default-chat-runtime.ts index f23fe74a7b..2b7742e56f 100644 --- a/src/agent/hosted/default-chat-runtime.ts +++ b/src/agent/hosted/default-chat-runtime.ts @@ -550,6 +550,9 @@ export async function createDefaultHostedChatRuntime( cloudContext, () => createPreparedHostedRuntimeAgent({ + runtimeAgentId: taskContext.agentId?.trim() + ? taskContext.agentId + : "veryfront-hosted-runtime", options: input.options, taskContext, toolAssembly, From 7011dea539694e1132c23f0c2a5cf3609a52a2f6 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Mon, 14 Sep 2026 09:28:25 +0200 Subject: [PATCH 4/6] fix(agents): preserve bound identity through replaced string intrinsics --- src/agent/factory.ts | 6 +- src/agent/hosted/default-chat-runtime.ts | 12 +-- ...hosted-runtime-identity-intrinsics.test.ts | 86 +++++++++++++++++++ 3 files changed, 98 insertions(+), 6 deletions(-) create mode 100644 tests/integration/agent/hosted-runtime-identity-intrinsics.test.ts diff --git a/src/agent/factory.ts b/src/agent/factory.ts index 48c8566bfc..8830470e9c 100644 --- a/src/agent/factory.ts +++ b/src/agent/factory.ts @@ -73,6 +73,7 @@ import { import type { RuntimeSkillDefinition } from "./runtime/skill-metadata.ts"; const IntrinsicReflectApply = Reflect.apply; +const IntrinsicStringTrim = String.prototype.trim; const IntrinsicArrayFilter = Array.prototype.filter; const IntrinsicObjectEntries = Object.entries; const IntrinsicObjectKeys = Object.keys; @@ -533,7 +534,10 @@ function createAgent( config: AgentConfig, options: { register: boolean; runtimeOptions?: AgentRuntimeInternalOptions }, ): Agent { - if (typeof config.id === "string" && config.id.trim().length === 0) { + if ( + typeof config.id === "string" && + IntrinsicReflectApply(IntrinsicStringTrim, config.id, []).length === 0 + ) { throw toError( createError({ type: "agent", diff --git a/src/agent/hosted/default-chat-runtime.ts b/src/agent/hosted/default-chat-runtime.ts index 2b7742e56f..7476324247 100644 --- a/src/agent/hosted/default-chat-runtime.ts +++ b/src/agent/hosted/default-chat-runtime.ts @@ -64,6 +64,7 @@ import { snapshotBoundedJsonValue } from "#veryfront/schemas/json-value.ts"; import { defineOwnDataProperty } from "#veryfront/security/own-data-property.ts"; const apply = Reflect.apply; +const stringTrim = String.prototype.trim; const TypeErrorConstructor = TypeError; const objectEntries = Object.entries; const objectSetPrototypeOf = Object.setPrototypeOf; @@ -322,6 +323,10 @@ export type PreparedHostedRuntimeAgentOptions = { refreshSystem?: () => Promise | AgentSystem; }; +function resolveRuntimeAgentId(agentId: string | undefined): string { + return agentId && apply(stringTrim, agentId, []) ? agentId : "veryfront-hosted-runtime"; +} + function createRuntimeAgentConfig(input: PreparedHostedRuntimeAgentOptions): AgentConfig { const liveProjectSteering = input.options.liveProjectSteering; const refreshSystem = input.refreshSystem; @@ -335,8 +340,7 @@ function createRuntimeAgentConfig(input: PreparedHostedRuntimeAgentOptions): Age refreshSystem, }); const runtimeConfig: RuntimeToolFilterConfig = { - id: input.runtimeAgentId ?? - (input.options.agentId?.trim() ? input.options.agentId : "veryfront-hosted-runtime"), + id: input.runtimeAgentId ?? resolveRuntimeAgentId(input.options.agentId), model: input.modelId, system: input.toolAssembly.systemMessages ?? input.toolAssembly.systemInstructions, tools: runtimeTools, @@ -550,9 +554,7 @@ export async function createDefaultHostedChatRuntime( cloudContext, () => createPreparedHostedRuntimeAgent({ - runtimeAgentId: taskContext.agentId?.trim() - ? taskContext.agentId - : "veryfront-hosted-runtime", + runtimeAgentId: resolveRuntimeAgentId(taskContext.agentId), options: input.options, taskContext, toolAssembly, diff --git a/tests/integration/agent/hosted-runtime-identity-intrinsics.test.ts b/tests/integration/agent/hosted-runtime-identity-intrinsics.test.ts new file mode 100644 index 0000000000..c89352e579 --- /dev/null +++ b/tests/integration/agent/hosted-runtime-identity-intrinsics.test.ts @@ -0,0 +1,86 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { it } from "#veryfront/testing/bdd.ts"; +import { + createDefaultHostedChatRuntime, + createPreparedHostedRuntimeAgent, +} from "#veryfront/agent/hosted/default-chat-runtime.ts"; + +const agentId = "bound-identity-intrinsics"; +const policy = { schemaVersion: 1, mode: "allowlist", integrations: {} } as const; + +for (const surface of ["default", "prepared"] as const) { + for (const replacement of ["blank", "throw"] as const) { + it(`preserves ${surface} identity with a ${replacement} trim replacement`, async () => { + const trim = String.prototype.trim; + const apply = Reflect.apply; + let calls = 0; + let cleanup: (() => Promise) | undefined; + let preparedId: string | undefined; + String.prototype.trim = function () { + if (String(this) === agentId) { + calls++; + if (replacement === "throw") throw new Error("project replacement invoked"); + return ""; + } + return apply(trim, this, []); + }; + try { + if (surface === "default") { + const runtime = await createDefaultHostedChatRuntime({ + sourceIntegrationPolicy: policy, + options: { + agentId, + projectId: "project-1", + authToken: "fixture", + instructions: "Fixture", + model: "openai/gpt-5.4", + }, + config: { + apiUrl: "https://api.example.test", + apiMcpUrl: "https://api.example.test/mcp", + }, + buildLocalTools: () => ({}), + createRemoteToolSource: (config) => ({ + id: config.id ?? "fixture", + listTools: async () => [], + executeTool: async () => ({}), + }), + preloadLatestConversationUserText: false, + }); + cleanup = runtime.cleanup; + } else { + const runtime = createPreparedHostedRuntimeAgent({ + options: { + agentId, + projectId: "project-1", + instructions: "Fixture", + model: "openai/gpt-5.4", + }, + modelId: "openai/gpt-5.4", + taskContext: {}, + sourceIntegrationPolicy: policy, + toolAssembly: { + sourceIntegrationPolicy: policy, + runtimeTools: {}, + remoteToolSources: [], + localToolNames: [], + remoteToolNames: [], + providerToolNames: [], + availableToolNames: [], + compatibleRemoteToolNames: [], + toolLoadingMode: "eager", + systemInstructions: "Fixture", + }, + }, {}); + preparedId = runtime.id; + } + } finally { + String.prototype.trim = trim; + await cleanup?.(); + } + assertEquals(calls, 0); + if (surface === "prepared") assertEquals(preparedId, agentId); + }); + } +} From c18054868fd9c3d814143f487bc9670f095c60e3 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Mon, 14 Sep 2026 09:32:03 +0200 Subject: [PATCH 5/6] test(agents): keep reflection probe bound to selected identity --- .../agent/executor-runtime-private-facades.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/integration/agent/executor-runtime-private-facades.test.ts b/tests/integration/agent/executor-runtime-private-facades.test.ts index 663a3fbd28..d660aa0882 100644 --- a/tests/integration/agent/executor-runtime-private-facades.test.ts +++ b/tests/integration/agent/executor-runtime-private-facades.test.ts @@ -141,12 +141,17 @@ describe("private executor facades", () => { Object.defineProperty(Object.prototype, "delegates", { configurable: true, get() { - if (this.id === "veryfront-hosted-runtime" && this.tools?.visible?.execute) { + if (this.id === coder.id && this.tools?.visible?.execute) { exposedFacadeValues++; } return undefined; }, }); + // Positive control: the probe must detect a reflected configuration + // carrying the selected agent identity before testing the private path. + Reflect.get({ id: coder.id, tools: { visible } }, "delegates"); + assertEquals(exposedFacadeValues, 1); + exposedFacadeValues = 0; Object.defineProperty(Object.prototype, "projectSteering", { configurable: true, get() { From 63ebeca2b18358493f6df87bb5fc3f44fc6397e4 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Mon, 14 Sep 2026 09:47:25 +0200 Subject: [PATCH 6/6] test(agents): cover blank and generated factory identities --- src/agent/factory.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/agent/factory.test.ts b/src/agent/factory.test.ts index 7cb8e9358b..a682d9ee57 100644 --- a/src/agent/factory.test.ts +++ b/src/agent/factory.test.ts @@ -101,6 +101,14 @@ describe("agent factory", () => { toolRegistryInternal.clearAll(); }); + it("rejects empty explicit identities and preserves valid or generated identities", () => { + for (const id of ["", " ", "\t\n"]) { + assertThrows(() => agent({ id, system: "Synthetic" }), Error, "Agent id cannot be empty"); + } + assertEquals(agent({ id: "bound-agent", system: "Synthetic" }).id, "bound-agent"); + assertEquals(typeof agent({ system: "Synthetic" }).id, "string"); + }); + it("bootstraps schema validation before registering universal skill tools", () => { resetExtensionContracts(); registerSkill("support-triage", createSkill("support-triage", "Triage support requests"));