feat(team-mode): add team runtime member resolver with tests
This commit is contained in:
@@ -0,0 +1,198 @@
|
|||||||
|
import { readFileSync } from "node:fs"
|
||||||
|
declare const require: (name: string) => any
|
||||||
|
const { describe, expect, mock, test, beforeEach } = require("bun:test")
|
||||||
|
import type { ExecutorContext } from "../../../tools/delegate-task/executor-types"
|
||||||
|
import type { Member } from "../types"
|
||||||
|
|
||||||
|
const resolveCategoryExecutionMock = mock()
|
||||||
|
const resolveSubagentExecutionMock = mock()
|
||||||
|
const buildSystemContentMock = mock(() => "resolved-system-content")
|
||||||
|
|
||||||
|
mock.module("./resolve-member-dependencies", () => ({
|
||||||
|
resolveCategoryExecution: resolveCategoryExecutionMock,
|
||||||
|
resolveSubagentExecution: resolveSubagentExecutionMock,
|
||||||
|
buildSystemContent: buildSystemContentMock,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { resolveMember, TeamMemberResolutionError } = await import("./resolve-member")
|
||||||
|
|
||||||
|
function createExecutorContext(): ExecutorContext {
|
||||||
|
return {
|
||||||
|
client: {} as ExecutorContext["client"],
|
||||||
|
manager: {} as ExecutorContext["manager"],
|
||||||
|
directory: "/tmp/team-mode-test",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resolveMember", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mock.restore()
|
||||||
|
resolveCategoryExecutionMock.mockReset()
|
||||||
|
resolveSubagentExecutionMock.mockReset()
|
||||||
|
buildSystemContentMock.mockReset()
|
||||||
|
buildSystemContentMock.mockImplementation(() => "resolved-system-content")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("routes category members through resolveCategoryExecution", async () => {
|
||||||
|
// given
|
||||||
|
const member = {
|
||||||
|
backendType: "in-process",
|
||||||
|
isActive: true,
|
||||||
|
kind: "category",
|
||||||
|
name: "m1",
|
||||||
|
category: "deep",
|
||||||
|
prompt: "impl X",
|
||||||
|
} satisfies Member
|
||||||
|
|
||||||
|
resolveCategoryExecutionMock.mockResolvedValue({
|
||||||
|
agentToUse: "sisyphus-junior",
|
||||||
|
categoryModel: { providerID: "openai", modelID: "gpt-5.4" },
|
||||||
|
categoryPromptAppend: "appendix",
|
||||||
|
maxPromptTokens: 512,
|
||||||
|
fallbackChain: [{ providers: ["openai"], model: "gpt-5.4-mini" }],
|
||||||
|
})
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await resolveMember(member, createExecutorContext(), "deep, quick")
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(resolveCategoryExecutionMock).toHaveBeenCalledTimes(1)
|
||||||
|
expect(resolveCategoryExecutionMock).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
category: "deep",
|
||||||
|
description: "Resolve team member",
|
||||||
|
load_skills: [],
|
||||||
|
prompt: "impl X",
|
||||||
|
run_in_background: false,
|
||||||
|
subagent_type: "sisyphus-junior",
|
||||||
|
},
|
||||||
|
createExecutorContext(),
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
)
|
||||||
|
expect(resolveSubagentExecutionMock).not.toHaveBeenCalled()
|
||||||
|
expect(result.agentToUse).toBe("sisyphus-junior")
|
||||||
|
expect(result.systemContent).toBe("resolved-system-content")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("routes subagent members through resolveSubagentExecution", async () => {
|
||||||
|
// given
|
||||||
|
const member = {
|
||||||
|
backendType: "in-process",
|
||||||
|
isActive: true,
|
||||||
|
kind: "subagent_type",
|
||||||
|
name: "m2",
|
||||||
|
subagent_type: "atlas",
|
||||||
|
prompt: "addendum",
|
||||||
|
} satisfies Member
|
||||||
|
|
||||||
|
resolveSubagentExecutionMock.mockResolvedValue({
|
||||||
|
agentToUse: "atlas",
|
||||||
|
categoryModel: { providerID: "openai", modelID: "gpt-5.4-mini" },
|
||||||
|
fallbackChain: [{ providers: ["openai"], model: "gpt-5.4-nano" }],
|
||||||
|
})
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await resolveMember(member, createExecutorContext(), "deep, quick", "sisyphus")
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(resolveSubagentExecutionMock).toHaveBeenCalledTimes(1)
|
||||||
|
expect(resolveSubagentExecutionMock).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
description: "Resolve team member",
|
||||||
|
load_skills: [],
|
||||||
|
prompt: "addendum",
|
||||||
|
run_in_background: false,
|
||||||
|
subagent_type: "atlas",
|
||||||
|
},
|
||||||
|
createExecutorContext(),
|
||||||
|
"sisyphus",
|
||||||
|
"deep, quick",
|
||||||
|
{
|
||||||
|
allowSisyphusJuniorDirect: true,
|
||||||
|
allowPrimaryAgentDelegation: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
expect(resolveCategoryExecutionMock).not.toHaveBeenCalled()
|
||||||
|
expect(result.agentToUse).toBe("atlas")
|
||||||
|
expect(result.systemContent).toBe("resolved-system-content")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("throws TeamMemberResolutionError without category fallback when subagent resolution fails", async () => {
|
||||||
|
// given
|
||||||
|
const member = {
|
||||||
|
backendType: "in-process",
|
||||||
|
isActive: true,
|
||||||
|
kind: "subagent_type",
|
||||||
|
name: "unknown",
|
||||||
|
subagent_type: "unknown-agent",
|
||||||
|
} satisfies Member
|
||||||
|
|
||||||
|
resolveSubagentExecutionMock.mockRejectedValue(new Error("unknown agent"))
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = resolveMember(member, createExecutorContext(), "deep, quick")
|
||||||
|
|
||||||
|
// then
|
||||||
|
await expect(result).rejects.toBeInstanceOf(TeamMemberResolutionError)
|
||||||
|
await expect(result).rejects.toThrow("Failed to resolve member 'unknown': unknown agent")
|
||||||
|
expect(resolveCategoryExecutionMock).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reuses buildSystemContent for both resolution kinds without custom prompt concatenation", async () => {
|
||||||
|
// given
|
||||||
|
const categoryMember = {
|
||||||
|
backendType: "in-process",
|
||||||
|
isActive: true,
|
||||||
|
kind: "category",
|
||||||
|
name: "m1",
|
||||||
|
category: "deep",
|
||||||
|
prompt: "impl X",
|
||||||
|
} satisfies Member
|
||||||
|
const subagentMember = {
|
||||||
|
backendType: "in-process",
|
||||||
|
isActive: true,
|
||||||
|
kind: "subagent_type",
|
||||||
|
name: "m2",
|
||||||
|
subagent_type: "atlas",
|
||||||
|
prompt: "addendum",
|
||||||
|
} satisfies Member
|
||||||
|
|
||||||
|
resolveCategoryExecutionMock.mockResolvedValue({
|
||||||
|
agentToUse: "sisyphus-junior",
|
||||||
|
categoryModel: { providerID: "openai", modelID: "gpt-5.4" },
|
||||||
|
categoryPromptAppend: "appendix",
|
||||||
|
maxPromptTokens: 128,
|
||||||
|
fallbackChain: [],
|
||||||
|
})
|
||||||
|
resolveSubagentExecutionMock.mockResolvedValue({
|
||||||
|
agentToUse: "atlas",
|
||||||
|
categoryModel: { providerID: "openai", modelID: "gpt-5.4-mini" },
|
||||||
|
fallbackChain: [],
|
||||||
|
})
|
||||||
|
const source = readFileSync(new URL("./resolve-member.ts", import.meta.url), "utf8")
|
||||||
|
|
||||||
|
// when
|
||||||
|
await resolveMember(categoryMember, createExecutorContext(), "deep, quick")
|
||||||
|
await resolveMember(subagentMember, createExecutorContext(), "deep, quick")
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(buildSystemContentMock).toHaveBeenCalledTimes(2)
|
||||||
|
expect(buildSystemContentMock).toHaveBeenNthCalledWith(1, {
|
||||||
|
agentName: "sisyphus-junior",
|
||||||
|
categoryPromptAppend: "appendix",
|
||||||
|
maxPromptTokens: 128,
|
||||||
|
model: { providerID: "openai", modelID: "gpt-5.4" },
|
||||||
|
})
|
||||||
|
expect(buildSystemContentMock).toHaveBeenNthCalledWith(2, {
|
||||||
|
agentName: "atlas",
|
||||||
|
categoryPromptAppend: undefined,
|
||||||
|
maxPromptTokens: undefined,
|
||||||
|
model: { providerID: "openai", modelID: "gpt-5.4-mini" },
|
||||||
|
})
|
||||||
|
expect(source).toContain("buildSystemContent({")
|
||||||
|
expect(source).not.toContain("member.prompt +")
|
||||||
|
expect(source).not.toContain("+ member.prompt")
|
||||||
|
expect(source).not.toContain(".join(")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import type { FallbackEntry } from "../../../shared/model-requirements"
|
||||||
|
import type { DelegatedModelConfig } from "../../../shared/model-resolution-types"
|
||||||
|
import type { ExecutorContext } from "../../../tools/delegate-task/executor-types"
|
||||||
|
import type { DelegateTaskArgs } from "../../../tools/delegate-task/types"
|
||||||
|
import type { Member } from "../types"
|
||||||
|
import {
|
||||||
|
buildSystemContent,
|
||||||
|
resolveCategoryExecution,
|
||||||
|
resolveSubagentExecution,
|
||||||
|
} from "./resolve-member-dependencies"
|
||||||
|
|
||||||
|
export class TeamMemberResolutionError extends Error {
|
||||||
|
constructor(public readonly memberName: string, public readonly cause: Error) {
|
||||||
|
super(`Failed to resolve member '${memberName}': ${cause.message}`)
|
||||||
|
this.name = "TeamMemberResolutionError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedMember {
|
||||||
|
memberName: string
|
||||||
|
agentToUse: string
|
||||||
|
model: DelegatedModelConfig | undefined
|
||||||
|
fallbackChain: FallbackEntry[] | undefined
|
||||||
|
systemContent: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBaseDelegateTaskArgs(prompt: string): Pick<DelegateTaskArgs, "description" | "load_skills" | "prompt" | "run_in_background"> {
|
||||||
|
return {
|
||||||
|
description: "Resolve team member",
|
||||||
|
load_skills: [],
|
||||||
|
prompt,
|
||||||
|
run_in_background: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeResolutionError(error: unknown): Error {
|
||||||
|
return error instanceof Error ? error : new Error(String(error))
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSystemContent(input: {
|
||||||
|
agentToUse: string
|
||||||
|
categoryPromptAppend?: string
|
||||||
|
maxPromptTokens?: number
|
||||||
|
model: DelegatedModelConfig | undefined
|
||||||
|
}): string {
|
||||||
|
return buildSystemContent({
|
||||||
|
agentName: input.agentToUse,
|
||||||
|
categoryPromptAppend: input.categoryPromptAppend,
|
||||||
|
maxPromptTokens: input.maxPromptTokens,
|
||||||
|
model: input.model,
|
||||||
|
}) ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveMember(
|
||||||
|
member: Member,
|
||||||
|
ctx: ExecutorContext,
|
||||||
|
categoryExamples: string,
|
||||||
|
parentAgent?: string,
|
||||||
|
): Promise<ResolvedMember> {
|
||||||
|
try {
|
||||||
|
if (member.kind === "category") {
|
||||||
|
const execution = await resolveCategoryExecution(
|
||||||
|
{
|
||||||
|
...createBaseDelegateTaskArgs(member.prompt),
|
||||||
|
category: member.category,
|
||||||
|
subagent_type: "sisyphus-junior",
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (execution.error) {
|
||||||
|
throw new Error(execution.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
memberName: member.name,
|
||||||
|
agentToUse: execution.agentToUse,
|
||||||
|
model: execution.categoryModel,
|
||||||
|
fallbackChain: execution.fallbackChain,
|
||||||
|
systemContent: resolveSystemContent({
|
||||||
|
agentToUse: execution.agentToUse,
|
||||||
|
categoryPromptAppend: execution.categoryPromptAppend,
|
||||||
|
maxPromptTokens: execution.maxPromptTokens,
|
||||||
|
model: execution.categoryModel,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const execution = await resolveSubagentExecution(
|
||||||
|
{
|
||||||
|
...createBaseDelegateTaskArgs(member.prompt ?? ""),
|
||||||
|
subagent_type: member.subagent_type,
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
parentAgent,
|
||||||
|
categoryExamples,
|
||||||
|
{
|
||||||
|
allowSisyphusJuniorDirect: true,
|
||||||
|
allowPrimaryAgentDelegation: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (execution.error) {
|
||||||
|
throw new Error(execution.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
memberName: member.name,
|
||||||
|
agentToUse: execution.agentToUse,
|
||||||
|
model: execution.categoryModel,
|
||||||
|
fallbackChain: execution.fallbackChain,
|
||||||
|
systemContent: resolveSystemContent({
|
||||||
|
agentToUse: execution.agentToUse,
|
||||||
|
model: execution.categoryModel,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new TeamMemberResolutionError(member.name, normalizeResolutionError(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user