Merge remote-tracking branch 'origin/master' into fix/subagent-empty-terminal-message-output
This commit is contained in:
@@ -45,9 +45,12 @@
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-include": "^1.0.4",
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-presets": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
|
||||
@@ -126,7 +126,7 @@ export async function startInProcessRun(
|
||||
if (inheritedPolicy !== undefined) {
|
||||
childSession.append('approval/policy', { policy: inheritedPolicy, source: 'delegation' })
|
||||
}
|
||||
applyChildComposition(childCtx, {
|
||||
applyChildComposition(childCtx, parent, {
|
||||
persona: request.persona,
|
||||
toolFilter: request.toolFilter,
|
||||
})
|
||||
|
||||
20
packages/subagent/subagent-inprocess/tests/fixtures/plugins/preset-tool.js
vendored
Normal file
20
packages/subagent/subagent-inprocess/tests/fixtures/plugins/preset-tool.js
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
// A preset row standing in for the agent-plane tool rows a real preset mounts.
|
||||
// Import-free on purpose — the Loader resolves entry modules through Node's ESM
|
||||
// resolver, which cannot see this workspace's TypeScript sources.
|
||||
export const name = 'preset-tool'
|
||||
export const inject = ['tools', 'systemPrompt']
|
||||
|
||||
export function apply(ctx, config) {
|
||||
ctx.effect(() => ctx.tools.register({
|
||||
name: config.tool,
|
||||
description: `fixture tool ${config.tool}`,
|
||||
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
||||
output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: String(value) }] },
|
||||
execute: () => Promise.resolve(config.tool),
|
||||
}))
|
||||
ctx.effect(() => ctx.systemPrompt.section({
|
||||
name: `preset:${config.tool}`,
|
||||
order: 10,
|
||||
text: `section for ${config.tool}`,
|
||||
}))
|
||||
}
|
||||
5
packages/subagent/subagent-inprocess/tests/fixtures/presets/coding/agent.cordis.yml
vendored
Normal file
5
packages/subagent/subagent-inprocess/tests/fixtures/presets/coding/agent.cordis.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# Agent-plane composition: the model-facing row lives here, not in the host.
|
||||
- id: only
|
||||
name: ../../plugins/preset-tool.js
|
||||
config:
|
||||
tool: preset_only
|
||||
6
packages/subagent/subagent-inprocess/tests/fixtures/presets/reviewing/agent.cordis.yml
vendored
Normal file
6
packages/subagent/subagent-inprocess/tests/fixtures/presets/reviewing/agent.cordis.yml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
# A second agent-plane composition, so a switch is a real switch: the tool a
|
||||
# joined child sees has to change with it.
|
||||
- id: only
|
||||
name: ../../plugins/preset-tool.js
|
||||
config:
|
||||
tool: reviewing_only
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Composition inheritance: a child runs on the preset its parent runs on.
|
||||
*
|
||||
* With every model-facing row on the agent plane, the tool registry's global
|
||||
* layer is empty, so a child that joins no preset reaches the model with no
|
||||
* tools at all. These assert the model-visible result — the schemas in the
|
||||
* child's own request — rather than the join that produces it.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Include from '@cordisjs/plugin-include'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import AgentPresets from '@deepseek-ai/dsh-agent-presets'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
|
||||
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import { startInProcessRun } from '../src/index.ts'
|
||||
|
||||
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
|
||||
const ROOTS = [{ path: join(FIXTURES, 'presets'), trust: 'system' as const }]
|
||||
|
||||
const contexts: Context[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
for (const ctx of contexts.splice(0).reverse()) await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
/** A host composition carrying no model-facing rows, plus the preset roster. */
|
||||
async function setupPresetHost(): Promise<{ ctx: Context; adapter: MockAdapter; parent: Agent }> {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
ctx.baseUrl = pathToFileURL(FIXTURES).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(AgentPresets, { default: 'coding', roots: ROOTS })
|
||||
const adapter = new MockAdapter([textResponse('parent idle'), textResponse('child done')])
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('parent'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'coding'),
|
||||
})
|
||||
return { ctx, adapter, parent: handle.agent }
|
||||
}
|
||||
|
||||
/** The one-shot spawn request shape both in-process providers build. */
|
||||
function spawnRequest(parent: Agent) {
|
||||
return {
|
||||
label: 'child task',
|
||||
prompt: [{ type: 'text' as const, text: 'child task' }],
|
||||
parent,
|
||||
signal: new AbortController().signal,
|
||||
descriptor: snapshotSubagentDescriptor({
|
||||
mode: 'one-shot' as const,
|
||||
provider: 'spawn',
|
||||
label: 'child task',
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
describe('a child agent composed in-process', () => {
|
||||
it('reaches the model with its parent\'s preset tools', async () => {
|
||||
const { ctx, adapter, parent } = await setupPresetHost()
|
||||
|
||||
const run = await startInProcessRun(spawnRequest(parent), {})
|
||||
await run.result
|
||||
|
||||
const childRequest = adapter.requests.at(-1)
|
||||
expect(childRequest?.tools?.map(tool => tool.name)).toEqual(['preset_only'])
|
||||
expect(ctx.tools.schemas(run.localAgent).map(schema => schema.name)).toEqual(['preset_only'])
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('carries its parent\'s prompt sections', async () => {
|
||||
const { parent } = await setupPresetHost()
|
||||
|
||||
const run = await startInProcessRun(spawnRequest(parent), {})
|
||||
await run.result
|
||||
|
||||
expect(run.localAgent?.session.events.some(event =>
|
||||
event.type === 'request/header'
|
||||
&& JSON.stringify(event.data).includes('section for preset_only'))).toBe(true)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('records the composition it ran under on the child header', async () => {
|
||||
const { parent } = await setupPresetHost()
|
||||
|
||||
const run = await startInProcessRun(spawnRequest(parent), {})
|
||||
await run.result
|
||||
|
||||
// Without this the child's own history reads back under the deployment
|
||||
// default, which is a different tool set than the one it actually used.
|
||||
expect(run.localAgent?.session.header.agentPreset).toBe('coding')
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('honours a tool filter over the preset tools it inherited', async () => {
|
||||
const { ctx, parent } = await setupPresetHost()
|
||||
|
||||
const run = await startInProcessRun(
|
||||
{ ...spawnRequest(parent), toolFilter: { deny: ['preset_only'] } },
|
||||
{},
|
||||
)
|
||||
await run.result
|
||||
|
||||
// The capability filter is the only thing bounding a delegated child, and
|
||||
// every tool it can name now arrives from the preset rather than the host.
|
||||
expect(ctx.tools.schemas(run.localAgent).map(schema => schema.name)).toEqual([])
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('follows a parent that switched preset while blank', async () => {
|
||||
const { ctx, parent } = await setupPresetHost()
|
||||
// A DIFFERENT preset, so the assertion below distinguishes reading the
|
||||
// parent's live scope chain from reading its creation header — re-linking
|
||||
// to the same id would pass either way.
|
||||
await ctx.agentPresets.recompose(parent.ctx, 'reviewing')
|
||||
|
||||
const run = await startInProcessRun(spawnRequest(parent), {})
|
||||
await run.result
|
||||
|
||||
expect(ctx.tools.schemas(run.localAgent).map(schema => schema.name)).toEqual(['reviewing_only'])
|
||||
expect(run.localAgent?.session.header.agentPreset).toBe('reviewing')
|
||||
await run.dispose()
|
||||
})
|
||||
})
|
||||
@@ -331,7 +331,7 @@ describe('startInProcessRun', () => {
|
||||
await expect(startInProcessRun({
|
||||
...request(parent),
|
||||
toolFilter: { deny: ['unknown-tool'] },
|
||||
}, {})).rejects.toThrow('unknown global tool')
|
||||
}, {})).rejects.toThrow('unknown inherited tool')
|
||||
expect(ctx.agents.list()).toHaveLength(beforeAgents)
|
||||
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user