fix(agent-teams): accept teammate agent IDs in messaging
Normalize send_message recipients so name@team values resolve to member names, preventing false recipient-not-found fallbacks into duplicate delegation paths. Also add delegation consistency coverage and split teammate runtime helpers for clearer spawn and parent-context handling.
This commit is contained in:
committed by
YeonGyu-Kim
parent
40f844fb85
commit
cf42082c5f
189
src/tools/agent-teams/delegation-consistency.test.ts
Normal file
189
src/tools/agent-teams/delegation-consistency.test.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
import { mkdtempSync, rmSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import { createAgentTeamsTools } from "./tools"
|
||||
|
||||
interface LaunchCall {
|
||||
description: string
|
||||
prompt: string
|
||||
agent: string
|
||||
parentSessionID: string
|
||||
parentMessageID: string
|
||||
parentAgent?: string
|
||||
parentModel?: {
|
||||
providerID: string
|
||||
modelID: string
|
||||
variant?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface ResumeCall {
|
||||
sessionId: string
|
||||
prompt: string
|
||||
parentSessionID: string
|
||||
parentMessageID: string
|
||||
parentAgent?: string
|
||||
parentModel?: {
|
||||
providerID: string
|
||||
modelID: string
|
||||
variant?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface ToolContextLike {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
abort: AbortSignal
|
||||
agent?: string
|
||||
}
|
||||
|
||||
function createMockManager(): {
|
||||
manager: BackgroundManager
|
||||
launchCalls: LaunchCall[]
|
||||
resumeCalls: ResumeCall[]
|
||||
} {
|
||||
const launchCalls: LaunchCall[] = []
|
||||
const resumeCalls: ResumeCall[] = []
|
||||
const launchedTasks = new Map<string, { id: string; sessionID: string }>()
|
||||
let launchCount = 0
|
||||
|
||||
const manager = {
|
||||
launch: async (args: LaunchCall) => {
|
||||
launchCount += 1
|
||||
launchCalls.push(args)
|
||||
const task = { id: `bg-${launchCount}`, sessionID: `ses-worker-${launchCount}` }
|
||||
launchedTasks.set(task.id, task)
|
||||
return task
|
||||
},
|
||||
getTask: (taskId: string) => launchedTasks.get(taskId),
|
||||
resume: async (args: ResumeCall) => {
|
||||
resumeCalls.push(args)
|
||||
return { id: `resume-${resumeCalls.length}` }
|
||||
},
|
||||
} as unknown as BackgroundManager
|
||||
|
||||
return { manager, launchCalls, resumeCalls }
|
||||
}
|
||||
|
||||
async function executeJsonTool(
|
||||
tools: ReturnType<typeof createAgentTeamsTools>,
|
||||
toolName: keyof ReturnType<typeof createAgentTeamsTools>,
|
||||
args: Record<string, unknown>,
|
||||
context: ToolContextLike,
|
||||
): Promise<unknown> {
|
||||
const output = await tools[toolName].execute(args, context as any)
|
||||
return JSON.parse(output)
|
||||
}
|
||||
|
||||
describe("agent-teams delegation consistency", () => {
|
||||
let originalCwd: string
|
||||
let tempProjectDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd()
|
||||
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-consistency-"))
|
||||
process.chdir(tempProjectDir)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd)
|
||||
rmSync(tempProjectDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test("team delegation forwards parent context like normal delegate-task", async () => {
|
||||
//#given
|
||||
const { manager, launchCalls, resumeCalls } = createMockManager()
|
||||
const tools = createAgentTeamsTools(manager)
|
||||
const leadContext: ToolContextLike = {
|
||||
sessionID: "ses-main",
|
||||
messageID: "msg-main",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext)
|
||||
|
||||
//#when
|
||||
const spawnResult = await executeJsonTool(
|
||||
tools,
|
||||
"spawn_teammate",
|
||||
{
|
||||
team_name: "core",
|
||||
name: "worker_1",
|
||||
prompt: "Handle release prep",
|
||||
category: "quick",
|
||||
},
|
||||
leadContext,
|
||||
) as { error?: string }
|
||||
|
||||
//#then
|
||||
expect(spawnResult.error).toBeUndefined()
|
||||
expect(launchCalls).toHaveLength(1)
|
||||
expect(launchCalls[0].parentAgent).toBe("sisyphus")
|
||||
expect("parentModel" in launchCalls[0]).toBe(true)
|
||||
|
||||
//#when
|
||||
const messageResult = await executeJsonTool(
|
||||
tools,
|
||||
"send_message",
|
||||
{
|
||||
team_name: "core",
|
||||
type: "message",
|
||||
recipient: "worker_1",
|
||||
summary: "sync",
|
||||
content: "Please update status.",
|
||||
},
|
||||
leadContext,
|
||||
) as { error?: string }
|
||||
|
||||
//#then
|
||||
expect(messageResult.error).toBeUndefined()
|
||||
expect(resumeCalls).toHaveLength(1)
|
||||
expect(resumeCalls[0].parentAgent).toBe("sisyphus")
|
||||
expect("parentModel" in resumeCalls[0]).toBe(true)
|
||||
})
|
||||
|
||||
test("send_message accepts teammate agent_id as recipient", async () => {
|
||||
//#given
|
||||
const { manager, resumeCalls } = createMockManager()
|
||||
const tools = createAgentTeamsTools(manager)
|
||||
const leadContext: ToolContextLike = {
|
||||
sessionID: "ses-main",
|
||||
messageID: "msg-main",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext)
|
||||
await executeJsonTool(
|
||||
tools,
|
||||
"spawn_teammate",
|
||||
{
|
||||
team_name: "core",
|
||||
name: "worker_1",
|
||||
prompt: "Handle release prep",
|
||||
category: "quick",
|
||||
},
|
||||
leadContext,
|
||||
)
|
||||
|
||||
//#when
|
||||
const messageResult = await executeJsonTool(
|
||||
tools,
|
||||
"send_message",
|
||||
{
|
||||
team_name: "core",
|
||||
type: "message",
|
||||
recipient: "worker_1@core",
|
||||
summary: "sync",
|
||||
content: "Please update status.",
|
||||
},
|
||||
leadContext,
|
||||
) as { error?: string }
|
||||
|
||||
//#then
|
||||
expect(messageResult.error).toBeUndefined()
|
||||
expect(resumeCalls).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
13
src/tools/agent-teams/teammate-parent-context.ts
Normal file
13
src/tools/agent-teams/teammate-parent-context.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { ParentContext } from "../delegate-task/executor"
|
||||
import { resolveParentContext } from "../delegate-task/executor"
|
||||
import type { ToolContextWithMetadata } from "../delegate-task/types"
|
||||
import type { TeamToolContext } from "./types"
|
||||
|
||||
export function resolveTeamParentContext(context: TeamToolContext): ParentContext {
|
||||
return resolveParentContext({
|
||||
sessionID: context.sessionID,
|
||||
messageID: context.messageID,
|
||||
agent: context.agent ?? "sisyphus",
|
||||
abort: new AbortController().signal,
|
||||
} as ToolContextWithMetadata)
|
||||
}
|
||||
28
src/tools/agent-teams/teammate-prompts.ts
Normal file
28
src/tools/agent-teams/teammate-prompts.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export function buildLaunchPrompt(
|
||||
teamName: string,
|
||||
teammateName: string,
|
||||
userPrompt: string,
|
||||
categoryPromptAppend?: string,
|
||||
): string {
|
||||
const sections = [
|
||||
`You are teammate "${teammateName}" in team "${teamName}".`,
|
||||
`When you need updates, call read_inbox with team_name="${teamName}" and agent_name="${teammateName}".`,
|
||||
"Initial assignment:",
|
||||
userPrompt,
|
||||
]
|
||||
|
||||
if (categoryPromptAppend) {
|
||||
sections.push("Category guidance:", categoryPromptAppend)
|
||||
}
|
||||
|
||||
return sections.join("\n\n")
|
||||
}
|
||||
|
||||
export function buildDeliveryPrompt(teamName: string, teammateName: string, summary: string, content: string): string {
|
||||
return [
|
||||
`New team message for "${teammateName}" in team "${teamName}".`,
|
||||
`Summary: ${summary}`,
|
||||
"Content:",
|
||||
content,
|
||||
].join("\n\n")
|
||||
}
|
||||
@@ -1,25 +1,10 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { CategoriesConfig } from "../../config/schema"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import { resolveCategoryExecution, resolveParentContext } from "../delegate-task/executor"
|
||||
import type { DelegateTaskArgs, ToolContextWithMetadata } from "../delegate-task/types"
|
||||
import { clearInbox, ensureInbox, sendPlainInboxMessage } from "./inbox-store"
|
||||
import { assignNextColor, getTeamMember, removeTeammate, updateTeamConfig, upsertTeammate } from "./team-config-store"
|
||||
import type { TeamTeammateMember, TeamToolContext } from "./types"
|
||||
|
||||
function parseModel(model: string | undefined): { providerID: string; modelID: string } | undefined {
|
||||
if (!model) {
|
||||
return undefined
|
||||
}
|
||||
const separatorIndex = model.indexOf("/")
|
||||
if (separatorIndex <= 0 || separatorIndex >= model.length - 1) {
|
||||
throw new Error("invalid_model_override_format")
|
||||
}
|
||||
|
||||
const providerID = model.slice(0, separatorIndex)
|
||||
const modelID = model.slice(separatorIndex + 1)
|
||||
return { providerID, modelID }
|
||||
}
|
||||
import { resolveTeamParentContext } from "./teammate-parent-context"
|
||||
import { buildDeliveryPrompt, buildLaunchPrompt } from "./teammate-prompts"
|
||||
import { resolveSpawnExecution, type TeamCategoryContext } from "./teammate-spawn-execution"
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
@@ -37,35 +22,6 @@ function resolveLaunchFailureMessage(status: string | undefined, error: string |
|
||||
return "teammate_launch_timeout"
|
||||
}
|
||||
|
||||
function buildLaunchPrompt(
|
||||
teamName: string,
|
||||
teammateName: string,
|
||||
userPrompt: string,
|
||||
categoryPromptAppend?: string,
|
||||
): string {
|
||||
const sections = [
|
||||
`You are teammate "${teammateName}" in team "${teamName}".`,
|
||||
`When you need updates, call read_inbox with team_name="${teamName}" and agent_name="${teammateName}".`,
|
||||
"Initial assignment:",
|
||||
userPrompt,
|
||||
]
|
||||
|
||||
if (categoryPromptAppend) {
|
||||
sections.push("Category guidance:", categoryPromptAppend)
|
||||
}
|
||||
|
||||
return sections.join("\n\n")
|
||||
}
|
||||
|
||||
function buildDeliveryPrompt(teamName: string, teammateName: string, summary: string, content: string): string {
|
||||
return [
|
||||
`New team message for "${teammateName}" in team "${teamName}".`,
|
||||
`Summary: ${summary}`,
|
||||
"Content:",
|
||||
content,
|
||||
].join("\n\n")
|
||||
}
|
||||
|
||||
export interface SpawnTeammateParams {
|
||||
teamName: string
|
||||
name: string
|
||||
@@ -76,99 +32,24 @@ export interface SpawnTeammateParams {
|
||||
planModeRequired: boolean
|
||||
context: TeamToolContext
|
||||
manager: BackgroundManager
|
||||
categoryContext?: {
|
||||
client: PluginInput["client"]
|
||||
userCategories?: CategoriesConfig
|
||||
sisyphusJuniorModel?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface SpawnExecution {
|
||||
agentType: string
|
||||
teammateModel: string
|
||||
launchModel?: { providerID: string; modelID: string; variant?: string }
|
||||
categoryPromptAppend?: string
|
||||
}
|
||||
|
||||
async function getSystemDefaultModel(client: PluginInput["client"]): Promise<string | undefined> {
|
||||
try {
|
||||
const openCodeConfig = await client.config.get()
|
||||
return (openCodeConfig as { data?: { model?: string } })?.data?.model
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveSpawnExecution(params: SpawnTeammateParams): Promise<SpawnExecution> {
|
||||
if (params.model) {
|
||||
const launchModel = parseModel(params.model)
|
||||
return {
|
||||
agentType: params.subagentType,
|
||||
teammateModel: params.model,
|
||||
...(launchModel ? { launchModel } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
if (!params.categoryContext?.client) {
|
||||
return {
|
||||
agentType: params.subagentType,
|
||||
teammateModel: "native",
|
||||
}
|
||||
}
|
||||
|
||||
const parentContext = resolveParentContext({
|
||||
sessionID: params.context.sessionID,
|
||||
messageID: params.context.messageID,
|
||||
agent: params.context.agent ?? "sisyphus",
|
||||
abort: new AbortController().signal,
|
||||
} as ToolContextWithMetadata)
|
||||
|
||||
const inheritedModel = parentContext.model
|
||||
? `${parentContext.model.providerID}/${parentContext.model.modelID}`
|
||||
: undefined
|
||||
|
||||
const systemDefaultModel = await getSystemDefaultModel(params.categoryContext.client)
|
||||
|
||||
const delegateArgs: DelegateTaskArgs = {
|
||||
description: `[team:${params.teamName}] ${params.name}`,
|
||||
prompt: params.prompt,
|
||||
category: params.category,
|
||||
subagent_type: "sisyphus-junior",
|
||||
run_in_background: true,
|
||||
load_skills: [],
|
||||
}
|
||||
|
||||
const resolution = await resolveCategoryExecution(
|
||||
delegateArgs,
|
||||
{
|
||||
manager: params.manager,
|
||||
client: params.categoryContext.client,
|
||||
directory: process.cwd(),
|
||||
userCategories: params.categoryContext.userCategories,
|
||||
sisyphusJuniorModel: params.categoryContext.sisyphusJuniorModel,
|
||||
},
|
||||
inheritedModel,
|
||||
systemDefaultModel,
|
||||
)
|
||||
|
||||
if (resolution.error) {
|
||||
throw new Error(resolution.error)
|
||||
}
|
||||
|
||||
if (!resolution.categoryModel) {
|
||||
throw new Error("category_model_not_resolved")
|
||||
}
|
||||
|
||||
return {
|
||||
agentType: resolution.agentToUse,
|
||||
teammateModel: `${resolution.categoryModel.providerID}/${resolution.categoryModel.modelID}`,
|
||||
launchModel: resolution.categoryModel,
|
||||
categoryPromptAppend: resolution.categoryPromptAppend,
|
||||
}
|
||||
categoryContext?: TeamCategoryContext
|
||||
}
|
||||
|
||||
export async function spawnTeammate(params: SpawnTeammateParams): Promise<TeamTeammateMember> {
|
||||
const execution = await resolveSpawnExecution(params)
|
||||
const parentContext = resolveTeamParentContext(params.context)
|
||||
const execution = await resolveSpawnExecution(
|
||||
{
|
||||
teamName: params.teamName,
|
||||
name: params.name,
|
||||
prompt: params.prompt,
|
||||
category: params.category,
|
||||
subagentType: params.subagentType,
|
||||
model: params.model,
|
||||
manager: params.manager,
|
||||
categoryContext: params.categoryContext,
|
||||
},
|
||||
parentContext,
|
||||
)
|
||||
|
||||
let teammate: TeamTeammateMember | undefined
|
||||
let launchedTaskID: string | undefined
|
||||
@@ -209,11 +90,12 @@ export async function spawnTeammate(params: SpawnTeammateParams): Promise<TeamTe
|
||||
description: `[team:${params.teamName}] ${params.name}`,
|
||||
prompt: buildLaunchPrompt(params.teamName, params.name, params.prompt, execution.categoryPromptAppend),
|
||||
agent: execution.agentType,
|
||||
parentSessionID: params.context.sessionID,
|
||||
parentMessageID: params.context.messageID,
|
||||
parentSessionID: parentContext.sessionID,
|
||||
parentMessageID: parentContext.messageID,
|
||||
parentModel: parentContext.model,
|
||||
...(execution.launchModel ? { model: execution.launchModel } : {}),
|
||||
...(params.category ? { category: params.category } : {}),
|
||||
parentAgent: params.context.agent,
|
||||
parentAgent: parentContext.agent,
|
||||
})
|
||||
launchedTaskID = launched.id
|
||||
|
||||
@@ -286,13 +168,16 @@ export async function resumeTeammateWithMessage(
|
||||
return
|
||||
}
|
||||
|
||||
const parentContext = resolveTeamParentContext(context)
|
||||
|
||||
try {
|
||||
await manager.resume({
|
||||
sessionId: teammate.sessionID,
|
||||
prompt: buildDeliveryPrompt(teamName, teammate.name, summary, content),
|
||||
parentSessionID: context.sessionID,
|
||||
parentMessageID: context.messageID,
|
||||
parentAgent: context.agent,
|
||||
parentSessionID: parentContext.sessionID,
|
||||
parentMessageID: parentContext.messageID,
|
||||
parentModel: parentContext.model,
|
||||
parentAgent: parentContext.agent,
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
|
||||
119
src/tools/agent-teams/teammate-spawn-execution.ts
Normal file
119
src/tools/agent-teams/teammate-spawn-execution.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { CategoriesConfig } from "../../config/schema"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import type { ParentContext } from "../delegate-task/executor"
|
||||
import { resolveCategoryExecution } from "../delegate-task/executor"
|
||||
import type { DelegateTaskArgs } from "../delegate-task/types"
|
||||
|
||||
function parseModel(model: string | undefined): { providerID: string; modelID: string } | undefined {
|
||||
if (!model) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const separatorIndex = model.indexOf("/")
|
||||
if (separatorIndex <= 0 || separatorIndex >= model.length - 1) {
|
||||
throw new Error("invalid_model_override_format")
|
||||
}
|
||||
|
||||
return {
|
||||
providerID: model.slice(0, separatorIndex),
|
||||
modelID: model.slice(separatorIndex + 1),
|
||||
}
|
||||
}
|
||||
|
||||
async function getSystemDefaultModel(client: PluginInput["client"]): Promise<string | undefined> {
|
||||
try {
|
||||
const openCodeConfig = await client.config.get()
|
||||
return (openCodeConfig as { data?: { model?: string } })?.data?.model
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export interface TeamCategoryContext {
|
||||
client: PluginInput["client"]
|
||||
userCategories?: CategoriesConfig
|
||||
sisyphusJuniorModel?: string
|
||||
}
|
||||
|
||||
export interface SpawnExecutionRequest {
|
||||
teamName: string
|
||||
name: string
|
||||
prompt: string
|
||||
category: string
|
||||
subagentType: string
|
||||
model?: string
|
||||
manager: BackgroundManager
|
||||
categoryContext?: TeamCategoryContext
|
||||
}
|
||||
|
||||
export interface SpawnExecutionResult {
|
||||
agentType: string
|
||||
teammateModel: string
|
||||
launchModel?: { providerID: string; modelID: string; variant?: string }
|
||||
categoryPromptAppend?: string
|
||||
}
|
||||
|
||||
export async function resolveSpawnExecution(
|
||||
request: SpawnExecutionRequest,
|
||||
parentContext: ParentContext,
|
||||
): Promise<SpawnExecutionResult> {
|
||||
if (request.model) {
|
||||
const launchModel = parseModel(request.model)
|
||||
return {
|
||||
agentType: request.subagentType,
|
||||
teammateModel: request.model,
|
||||
...(launchModel ? { launchModel } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
if (!request.categoryContext?.client) {
|
||||
return {
|
||||
agentType: request.subagentType,
|
||||
teammateModel: "native",
|
||||
}
|
||||
}
|
||||
|
||||
const inheritedModel = parentContext.model
|
||||
? `${parentContext.model.providerID}/${parentContext.model.modelID}`
|
||||
: undefined
|
||||
|
||||
const systemDefaultModel = await getSystemDefaultModel(request.categoryContext.client)
|
||||
|
||||
const delegateArgs: DelegateTaskArgs = {
|
||||
description: `[team:${request.teamName}] ${request.name}`,
|
||||
prompt: request.prompt,
|
||||
category: request.category,
|
||||
subagent_type: "sisyphus-junior",
|
||||
run_in_background: true,
|
||||
load_skills: [],
|
||||
}
|
||||
|
||||
const resolution = await resolveCategoryExecution(
|
||||
delegateArgs,
|
||||
{
|
||||
manager: request.manager,
|
||||
client: request.categoryContext.client,
|
||||
directory: process.cwd(),
|
||||
userCategories: request.categoryContext.userCategories,
|
||||
sisyphusJuniorModel: request.categoryContext.sisyphusJuniorModel,
|
||||
},
|
||||
inheritedModel,
|
||||
systemDefaultModel,
|
||||
)
|
||||
|
||||
if (resolution.error) {
|
||||
throw new Error(resolution.error)
|
||||
}
|
||||
|
||||
if (!resolution.categoryModel) {
|
||||
throw new Error("category_model_not_resolved")
|
||||
}
|
||||
|
||||
return {
|
||||
agentType: resolution.agentToUse,
|
||||
teammateModel: `${resolution.categoryModel.providerID}/${resolution.categoryModel.modelID}`,
|
||||
launchModel: resolution.categoryModel,
|
||||
categoryPromptAppend: resolution.categoryPromptAppend,
|
||||
}
|
||||
}
|
||||
@@ -115,10 +115,26 @@ export const TeamSpawnInputSchema = z.object({
|
||||
plan_mode_required: z.boolean().optional(),
|
||||
})
|
||||
|
||||
function normalizeTeamRecipient(recipient: string): string {
|
||||
const trimmed = recipient.trim()
|
||||
const atIndex = trimmed.indexOf("@")
|
||||
if (atIndex <= 0) {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
return trimmed.slice(0, atIndex)
|
||||
}
|
||||
|
||||
export const TeamSendMessageInputSchema = z.object({
|
||||
team_name: z.string(),
|
||||
type: TeamSendMessageTypeSchema,
|
||||
recipient: z.string().optional(),
|
||||
recipient: z.string().optional().transform((value) => {
|
||||
if (value === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return normalizeTeamRecipient(value)
|
||||
}),
|
||||
content: z.string().optional(),
|
||||
summary: z.string().optional(),
|
||||
request_id: z.string().optional(),
|
||||
|
||||
Reference in New Issue
Block a user