|
|
|
|
@@ -1,12 +1,11 @@
|
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
|
|
|
import { Context } from 'cordis'
|
|
|
|
|
import LlmService, { CallId } from '@deepseek-ai/dsh-llm'
|
|
|
|
|
import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session'
|
|
|
|
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
|
|
|
|
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
|
|
|
|
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
|
|
|
|
import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
|
|
|
|
|
import { CallId } from '@deepseek-ai/dsh-llm'
|
|
|
|
|
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
|
|
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
|
|
|
import { AgentId } from '@deepseek-ai/dsh-agent'
|
|
|
|
|
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
|
|
|
|
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
|
|
|
|
import * as RepeatToolGuard from '@deepseek-ai/dsh-repeat-tool-guard'
|
|
|
|
|
import type { Config } from '@deepseek-ai/dsh-repeat-tool-guard'
|
|
|
|
|
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
|
|
|
|
@@ -22,12 +21,7 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent
|
|
|
|
|
/** Boot the core spine + the guard; the caller registers adapters and extra listeners. */
|
|
|
|
|
async function harness(config: Config = {}): Promise<Context> {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(LlmService)
|
|
|
|
|
await ctx.plugin(SessionStore)
|
|
|
|
|
await ctx.plugin(SystemPrompt)
|
|
|
|
|
await ctx.plugin(ToolRegistry)
|
|
|
|
|
await ctx.plugin(AgentRegistry)
|
|
|
|
|
await ctx.plugin(AgentExecutionProvider)
|
|
|
|
|
await mountAgentLoopTestDependencies(ctx)
|
|
|
|
|
await ctx.plugin(AgentLoop, { agents: [] })
|
|
|
|
|
await ctx.plugin(RepeatToolGuard, config)
|
|
|
|
|
ctx.tools.register(defineTool({ name: 'probe', description: 'p', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
|
|
|
|
@@ -59,7 +53,7 @@ describe('threshold escalation', () => {
|
|
|
|
|
textResponse('done'),
|
|
|
|
|
])
|
|
|
|
|
ctx.llm.registerAdapter(['mock'], adapter)
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }])
|
|
|
|
|
await waitForIdle(ctx, agent)
|
|
|
|
|
|
|
|
|
|
@@ -80,7 +74,7 @@ describe('threshold escalation', () => {
|
|
|
|
|
textResponse('done'),
|
|
|
|
|
])
|
|
|
|
|
ctx.llm.registerAdapter(['mock'], adapter)
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }])
|
|
|
|
|
await waitForIdle(ctx, agent)
|
|
|
|
|
|
|
|
|
|
@@ -102,7 +96,7 @@ describe('chain semantics', () => {
|
|
|
|
|
textResponse('done'),
|
|
|
|
|
])
|
|
|
|
|
ctx.llm.registerAdapter(['mock'], adapter)
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }])
|
|
|
|
|
await waitForIdle(ctx, agent)
|
|
|
|
|
|
|
|
|
|
@@ -126,7 +120,7 @@ describe('chain semantics', () => {
|
|
|
|
|
textResponse('done'),
|
|
|
|
|
])
|
|
|
|
|
ctx.llm.registerAdapter(['mock'], adapter)
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }])
|
|
|
|
|
await waitForIdle(ctx, agent)
|
|
|
|
|
|
|
|
|
|
@@ -144,7 +138,7 @@ describe('chain semantics', () => {
|
|
|
|
|
textResponse('done'),
|
|
|
|
|
])
|
|
|
|
|
ctx.llm.registerAdapter(['mock'], adapter)
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }])
|
|
|
|
|
await waitForIdle(ctx, agent)
|
|
|
|
|
|
|
|
|
|
@@ -165,7 +159,7 @@ describe('chain semantics', () => {
|
|
|
|
|
textResponse('done'),
|
|
|
|
|
])
|
|
|
|
|
ctx.llm.registerAdapter(['mock'], adapter)
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }])
|
|
|
|
|
await waitForIdle(ctx, agent)
|
|
|
|
|
|
|
|
|
|
@@ -181,7 +175,7 @@ describe('chain semantics', () => {
|
|
|
|
|
textResponse('done'),
|
|
|
|
|
])
|
|
|
|
|
ctx.llm.registerAdapter(['mock'], adapter)
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }])
|
|
|
|
|
await waitForIdle(ctx, agent)
|
|
|
|
|
|
|
|
|
|
@@ -197,7 +191,7 @@ describe('chain semantics', () => {
|
|
|
|
|
textResponse('done'),
|
|
|
|
|
])
|
|
|
|
|
ctx.llm.registerAdapter(['mock'], adapter)
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }])
|
|
|
|
|
await waitForIdle(ctx, agent)
|
|
|
|
|
|
|
|
|
|
@@ -217,8 +211,8 @@ describe('chain semantics', () => {
|
|
|
|
|
toolCallResponse('b3', 'probe', { q: 1 }),
|
|
|
|
|
textResponse('done'),
|
|
|
|
|
]))
|
|
|
|
|
const agentA = ctx.agentLoop.create(AgentId('a'), { model: 'mock-a' })
|
|
|
|
|
const agentB = ctx.agentLoop.create(AgentId('b'), { model: 'mock-b' })
|
|
|
|
|
const agentA = ctx.agentLoop.create(AgentId('a'), { provider: 'mock-a', model: 'model-a' })
|
|
|
|
|
const agentB = ctx.agentLoop.create(AgentId('b'), { provider: 'mock-b', model: 'model-b' })
|
|
|
|
|
agentA.send([{ type: 'text', text: 'go' }])
|
|
|
|
|
agentB.send([{ type: 'text', text: 'go' }])
|
|
|
|
|
await Promise.all([waitForIdle(ctx, agentA), waitForIdle(ctx, agentB)])
|
|
|
|
|
@@ -237,7 +231,7 @@ describe('chain semantics', () => {
|
|
|
|
|
textResponse('turn two done'),
|
|
|
|
|
])
|
|
|
|
|
ctx.llm.registerAdapter(['mock'], adapter)
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }])
|
|
|
|
|
await waitForIdle(ctx, agent)
|
|
|
|
|
agent.send([{ type: 'text', text: 'again' }])
|
|
|
|
|
@@ -258,14 +252,14 @@ describe('chain semantics', () => {
|
|
|
|
|
// (the loop.spec pattern): a child plugin fiber owns `first`.
|
|
|
|
|
let first!: ReactLoopAgent
|
|
|
|
|
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
|
|
|
|
first = inner.agentLoop.create(AgentId('reused'), { model: 'mock' })
|
|
|
|
|
first = inner.agentLoop.create(AgentId('reused'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
}, { inject: ['agentLoop'] }))
|
|
|
|
|
first.send([{ type: 'text', text: 'go' }])
|
|
|
|
|
await waitForIdle(ctx, first)
|
|
|
|
|
await fiber.dispose()
|
|
|
|
|
await first.done
|
|
|
|
|
|
|
|
|
|
const second = ctx.agentLoop.create(AgentId('reused'), { model: 'mock' })
|
|
|
|
|
const second = ctx.agentLoop.create(AgentId('reused'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
second.send([{ type: 'text', text: 'go' }])
|
|
|
|
|
await waitForIdle(ctx, second)
|
|
|
|
|
|
|
|
|
|
@@ -281,7 +275,7 @@ describe('chain semantics', () => {
|
|
|
|
|
textResponse('done'),
|
|
|
|
|
])
|
|
|
|
|
ctx.llm.registerAdapter(['mock'], adapter)
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }])
|
|
|
|
|
await waitForIdle(ctx, agent)
|
|
|
|
|
|
|
|
|
|
@@ -297,7 +291,7 @@ describe('chain semantics', () => {
|
|
|
|
|
toolCallResponse('c1', 'probe', { q: 1 }), // if the direct call had counted, this would be #2
|
|
|
|
|
textResponse('done'),
|
|
|
|
|
]))
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }])
|
|
|
|
|
await waitForIdle(ctx, agent)
|
|
|
|
|
|
|
|
|
|
@@ -311,7 +305,7 @@ describe('fold onto the downstream decision', () => {
|
|
|
|
|
ctx.on('tools/post-execute', async () => ({
|
|
|
|
|
kind: 'block' as const,
|
|
|
|
|
feedback: [{ type: 'text' as const, text: 'nope' }],
|
|
|
|
|
additionalContext: { content: [{ type: 'text' as const, text: 'downstream-ctx' }], source: { kind: 'plugin' as const, plugin: 'test' } },
|
|
|
|
|
additionalContexts: [{ content: [{ type: 'text' as const, text: 'downstream-ctx' }], source: { kind: 'plugin' as const, plugin: 'test' } }],
|
|
|
|
|
}))
|
|
|
|
|
const adapter = new MockAdapter([
|
|
|
|
|
toolCallResponse('c1', 'probe', { q: 1 }),
|
|
|
|
|
@@ -319,19 +313,19 @@ describe('fold onto the downstream decision', () => {
|
|
|
|
|
textResponse('done'),
|
|
|
|
|
])
|
|
|
|
|
ctx.llm.registerAdapter(['mock'], adapter)
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }])
|
|
|
|
|
await waitForIdle(ctx, agent)
|
|
|
|
|
|
|
|
|
|
const found = reminders(agent)
|
|
|
|
|
expect(found).toHaveLength(2)
|
|
|
|
|
expect(found).toHaveLength(3)
|
|
|
|
|
// Call 1: below threshold — the downstream context passes through untouched.
|
|
|
|
|
expect(found[0]!.text).toBe('downstream-ctx')
|
|
|
|
|
expect(found[0]!.source).toEqual({ kind: 'plugin', plugin: 'test' })
|
|
|
|
|
// Call 2: reminder folded in front, single merged context, the guard's source.
|
|
|
|
|
// Call 2: reminder and downstream context retain separate provenance.
|
|
|
|
|
expect(found[1]!.text).toContain('repeating the exact same tool call')
|
|
|
|
|
expect(found[1]!.text).toContain('|downstream-ctx')
|
|
|
|
|
expect(found[1]!.source).toEqual(GUARD_SOURCE)
|
|
|
|
|
expect(found[2]).toEqual({ text: 'downstream-ctx', source: { kind: 'plugin', plugin: 'test' } })
|
|
|
|
|
// The block's feedback reached the tool result unchanged.
|
|
|
|
|
const results = [...agent.session.events].filter((e): e is SessionEvent<'tool/result'> => e.type === 'tool/result')
|
|
|
|
|
expect(results.every(r => r.data.isError)).toBe(true)
|
|
|
|
|
@@ -350,7 +344,7 @@ describe('fold onto the downstream decision', () => {
|
|
|
|
|
textResponse('done'),
|
|
|
|
|
])
|
|
|
|
|
ctx.llm.registerAdapter(['mock'], adapter)
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
|
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
agent.send([{ type: 'text', text: 'go' }])
|
|
|
|
|
await waitForIdle(ctx, agent)
|
|
|
|
|
|
|
|
|
|
@@ -365,12 +359,7 @@ describe('fold onto the downstream decision', () => {
|
|
|
|
|
describe('config validation fails loud', () => {
|
|
|
|
|
async function spine(): Promise<Context> {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(LlmService)
|
|
|
|
|
await ctx.plugin(SessionStore)
|
|
|
|
|
await ctx.plugin(SystemPrompt)
|
|
|
|
|
await ctx.plugin(ToolRegistry)
|
|
|
|
|
await ctx.plugin(AgentRegistry)
|
|
|
|
|
await ctx.plugin(AgentExecutionProvider)
|
|
|
|
|
await mountAgentLoopTestDependencies(ctx)
|
|
|
|
|
await ctx.plugin(AgentLoop, { agents: [] })
|
|
|
|
|
return ctx
|
|
|
|
|
}
|
|
|
|
|
|