diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index ec81187785..db0acfe414 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 6362022dd1b80e17997684574d9dc7f14372e23a -config-catalog.zh.md: 3fd335ee2b1ec6d1526f75ed9667f1d9286faafe +config-catalog.md: 39433b697588ede7b36f878490f4e0b095e8d67a +config-catalog.zh.md: d76b289f8a7ffbc434b2adeefd5f16142fdffc99 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6362022dd1..39433b6975 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2505,7 +2505,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:629`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:654`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-typert-loader` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 3fd335ee2b..d76b289f8a 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2506,7 +2506,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -来源:[`packages/core/tools/src/index.ts:617`](../packages/core/tools/src/index.ts) +来源:[`packages/core/tools/src/index.ts:654`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-typert-loader` diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 492112405b..b6be912c17 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -1221,7 +1221,7 @@ export class ToolRegistry extends Service { private resolveExecution(name: string, scope: ScopeKey | undefined, nested: boolean): ToolDefinition | undefined { const tool = this.get(name, scope) if (tool === undefined) return undefined - if (this.collapses(name, nested)) return undefined + if (this.collapses(name, scope, nested)) return undefined return tool } @@ -1311,11 +1311,18 @@ export class ToolRegistry extends Service { * `parent` token set) bypass the collapse. One home for the * security-relevant predicate, shared by {@link resolveExecution} and * {@link createExecution} so the two can never drift apart. + * + * Resolved through {@link modeFor}, NOT `defaultMode`: an agent given `code` + * by an agent preset under a native deployment is the composition + * `dsh-agent-tool-mode` exists for, and reading the deployment default would + * leave exactly that agent uncollapsed — announcing one surface while + * executing another, which is the bypass this collapse closes. * @param name - the tool name as registered. + * @param scope - the viewing scope whose effective presentation mode applies. * @param nested - whether the call is a transport sub-dispatch, not a model-direct call. */ - private collapses(name: string, nested: boolean): boolean { - return !nested && this.defaultMode === 'code' && name !== RUN_CODE_NAME + private collapses(name: string, scope: ScopeKey | undefined, nested: boolean): boolean { + return !nested && this.modeFor(scope) === 'code' && name !== RUN_CODE_NAME } /** @@ -1371,7 +1378,7 @@ export class ToolRegistry extends Service { // keeps the historical dispatch-stage `UNKNOWN_TOOL` path so policy // listeners still see every name that reaches the registry. const visible = this.get(name, agent) - const collapsed = visible !== undefined && this.collapses(name, parent !== undefined) + const collapsed = visible !== undefined && this.collapses(name, agent, parent !== undefined) const concludingExecutions = this.concludingExecutions const base = { token, diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index eb6d1b22e0..4379f7e0b2 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -1635,7 +1635,7 @@ describe('the run_code dispatch bridge', () => { describe('per-agent presentation', () => { it('gives one agent Code Mode while the deployment stays native', async () => { const { ctx, systemPrompt } = await setup({ mode: 'native' }) - registerEcho(ctx) + const calls = registerEcho(ctx) const { scope, agent } = await mintAgentScope(ctx) scope.ctx.tools.presentAs('code') @@ -1644,6 +1644,17 @@ describe('per-agent presentation', () => { expect(coded.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME]) expect(coded.sections.find(section => section.name === 'tools:sdk')?.text) .toContain('echo') + // Announced surface and callable surface must agree for THIS agent, whose + // mode is its own rather than the deployment's. + const denied = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('coded-direct'), + name: 'echo', + arguments: { value: 'coded' }, + agent, + }) + expect(denied.error?.info).toEqual({ name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' }) + expect(calls).toEqual([]) // The deployment default is untouched: an agent that declared nothing — // and the global view behind it — still sees the native catalog. const native = await systemPrompt.assemble() @@ -1654,7 +1665,7 @@ describe('per-agent presentation', () => { it('inherits a STANDING preset scope\'s mode down the chain, agents beside it unaffected', async () => { const { bindScopeParent } = await import('@deepseek-ai/dsh-scope') const { ctx, systemPrompt } = await setup({ mode: 'native' }) - registerEcho(ctx) + const calls = registerEcho(ctx) // The preset's standing scope declares once; the agent only PARENTS to it // (the per-preset standing mount configuration has no per-agent declaration). const standing = await mintAgentScope(ctx, 'preset:code-like') @@ -1666,10 +1677,40 @@ describe('per-agent presentation', () => { expect(ctx.tools.get(RUN_CODE_NAME, joined.agent)).toBeDefined() const coded = await systemPrompt.assemble({ scope: joined.agent }) expect(coded.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME]) + // Through the EXECUTOR, not just the wire: the deployment default is + // `native` here, so a collapse predicate reading it instead of this + // scope's effective mode would announce [run_code] and still execute the + // native call — the bypass, reopened for exactly the preset composition + // `dsh-agent-tool-mode` produces. + expect(ctx.tools.executionMode({ + signal: testToolSignal, + callId: CallId('preset-coded-schedule'), + name: 'echo', + arguments: { value: 'joined' }, + agent: joined.agent, + })).toEqual({ kind: 'exclusive' }) + const denied = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('preset-coded-direct'), + name: 'echo', + arguments: { value: 'joined' }, + agent: joined.agent, + }) + expect(denied.error?.info).toEqual({ name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' }) + expect(calls).toEqual([]) // A sibling that never parented stays native, as does the global view. expect(ctx.tools.get(RUN_CODE_NAME, loner.agent)).toBeUndefined() const native = await systemPrompt.assemble({ scope: loner.agent }) expect(native.tools.map(tool => tool.name)).toEqual(['echo']) + const allowed = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('native-sibling-direct'), + name: 'echo', + arguments: { value: 'loner' }, + agent: loner.agent, + }) + expect(allowed).toMatchObject({ isError: false, value: 'echo:loner' }) + expect(calls).toEqual([{ value: 'loner' }]) }) it('keeps run_code out of a native agent\'s dispatch table', async () => {