Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ Coding Code 的工具系统是 Agent 与外部世界交互的核心机制。本

| 工具 | 功能 | 关键参数 |
|---|---|---|
| `dispatch_agent` | 将任务委派给运行时注册的子智能体 | `agent: string`, `prompt: string` |
| `spawn_agent` | 启动一个后台子智能体并立即返回其会话 id,结果完成后自动注入本会话 | `agentName: string`, `prompt: string`, `model?: string`, `systemPrompt?: string` |
| `wait_agent` | 等待子智能体到达终态,返回 `completed` / `failed` / `timeout` | `sessionId: string`, `timeoutMs?: number`(夹在 `[10000, 3600000]`,默认 `30000`) |

---

Expand All @@ -50,7 +51,7 @@ Coding Code 的工具系统是 Agent 与外部世界交互的核心机制。本
- **Core 工具**:始终可用,在启动时注册。包括上述所有内置工具。
- **MCP 工具**:从 MCP 服务自动导入和注册。名称空间化为 `serverName:toolName` 格式,避免不同服务间的工具名冲突。

Agent 在一次运行开始时注册内置工具、项目 MCP 工具和 `dispatch_agent`。plan 模式通过独立的 `PLAN_PROFILE_ALLOWED_TOOLS` 策略过滤工具。
Agent 在一次运行开始时注册内置工具、项目 MCP 工具和 `spawn_agent` / `wait_agent`。plan 模式通过独立的 `PLAN_PROFILE_ALLOWED_TOOLS` 策略过滤工具。

---

Expand Down
16 changes: 14 additions & 2 deletions packages/codingcode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ApprovalService } from '../approval/port.js';
import { CheckpointService } from '../checkpoint/port.js';
import { ContextService } from '../context/port.js';
import { EventSinkService } from '../sink/port.js';
import { MailboxService } from '../session/mailbox.js';
import { HookService } from '../hooks/port.js';
import { LLMService } from '../llm/port.js';
import { McpService } from '../mcp/port.js';
Expand All @@ -17,7 +18,7 @@ import { SkillService } from '../skills/port.js';
import { TodoService } from '../todo/port.js';
import { ToolExecutorService } from '../tools/port.js';
import { buildSystemPrompt } from './prompt.js';
import type { FrameBody, FrameError, ResponseMeta, ToolOutcome, Transition } from '../contracts/frame.js';
import type { EndTransition, FrameBody, FrameError, ResponseMeta, ToolOutcome } from '../contracts/frame.js';
import { isTurnEnd } from '../contracts/frame.js';
import type { SessionRef } from '../contracts/session.js';
import type { ToolCatalog, ToolResult } from '../contracts/tool.js';
Expand Down Expand Up @@ -51,6 +52,7 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () {
const mcp = yield* McpService;
const context = yield* ContextService;
const sink = yield* EventSinkService;
const mailbox = yield* MailboxService;
const memory = yield* MemoryService;
const llm = yield* LLMService;
const rules = yield* RulesService;
Expand Down Expand Up @@ -196,7 +198,8 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () {
const { tools, lookup: toolLookup } = catalog;

let ended = false;
const offerEnd = (transition: Extract<Transition, { to: 'end' }>) =>
let deliveryPhase: 'currentTurn' | 'nextTurn' = 'currentTurn';
const offerEnd = (transition: EndTransition) =>
Effect.sync(() => {
if (ended) return;
ended = true;
Expand Down Expand Up @@ -238,6 +241,14 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () {
currentTurnId: state.currentTurnId,
};

const mayDrain = deliveryPhase === 'currentTurn' && step > 0;
if (mayDrain) {
for (const item of yield* mailbox.drain(state.sessionId)) {
const ev = yield* session.recordSubagentResult(state, item);
yield* context.absorb(sessionRef, [ev]);
}
}

const history = yield* Effect.either(context.getHistory(sessionRef, model));
if (Either.isLeft(history)) {
yield* offerEnd({ to: 'end', reason: 'error', error: toFrameError(history.left) });
Expand Down Expand Up @@ -284,6 +295,7 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () {
if (toolCalls.length === 0) {
const assistantEv = yield* session.recordAssistant(state, content, [], responded.usage);
yield* context.absorb(sessionRef, [assistantEv]);
deliveryPhase = 'nextTurn';
const stopDecision = yield* hooks.emitDecision('agent.turn.stop', { sessionId: sid, content, turnId: state.currentTurnId, projectPath });

if (stopDecision && stopDecision.decision === 'continue') {
Expand Down
5 changes: 3 additions & 2 deletions packages/codingcode/src/agent/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export const BUILD_PROMPT = `You are a coding assistant —an AI agent that help
7. For complex or broad tasks (understanding a whole module, cross-file analysis, comprehensive search):
a. Briefly assess the task scope using your own reasoning —do not use tools for exploration at this stage, as that would consume your limited context window.
b. If you can clearly handle it without extensive file reading or searching, proceed yourself.
c. Otherwise delegate the exploration with dispatch_agent: give the subagent a short agentName and a self-contained prompt. The subagent shares your working directory, so keep the delegated write set disjoint from your own.
c. Otherwise spawn_agent: give the subagent a short agentName, a self-contained prompt, and a write set that does not overlap yours. Then keep working on your own part; call wait_agent only when its result blocks your next step, and give the wait a generous timeout instead of polling.

## Using your tools
- **Prefer dedicated tools over shell commands.** Use read_file instead of cat, edit_file instead of sed, search_code instead of grep. Dedicated tools give the user better visibility into your work.
Expand Down Expand Up @@ -127,7 +127,8 @@ export const BUILD_TOOL_NAMES: readonly string[] = [
'fetch_url',
'web_search',
'todo_write',
'dispatch_agent',
'spawn_agent',
'wait_agent',
];

export function isPlanProfile(p: { name: string } | null | undefined): boolean {
Expand Down
3 changes: 3 additions & 0 deletions packages/codingcode/src/context/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,9 @@ export function buildContextMessages(
case 'summary':
messages.push({ role: 'system', name: 'compacted_history', content: event.summaryText });
break;
case 'subagent_result':
messages.push({ role: 'assistant', content: event.content });
break;
}
}

Expand Down
9 changes: 7 additions & 2 deletions packages/codingcode/src/contracts/frame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ export type RuntimeEvent =
readonly id: string;
readonly tool: string;
readonly args: Readonly<Record<string, unknown>>;
}
| {
readonly type: 'subagent_event';
readonly sessionId: string;
readonly agentName: string;
readonly status: 'spawned' | 'completed' | 'failed';
};

export interface Fatal {
Expand All @@ -66,8 +72,7 @@ export type FrameBody =
| { readonly family: 'fatal'; readonly fatal: Fatal };

export type Frame = Envelope & FrameBody;

type EndTransition = Extract<Transition, { to: 'end' }>;
export type EndTransition = Extract<Transition, { to: 'end' }>;

export function isTurnEnd(
body: FrameBody
Expand Down
10 changes: 9 additions & 1 deletion packages/codingcode/src/contracts/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,22 @@ export interface CompactEvent {
endTurnId: number;
}

export interface SubagentResultEvent {
type: 'subagent_result';
sessionId: string;
agentName: string;
content: string;
}

export type SessionEvent =
| SessionMetaEvent
| UserEvent
| AssistantEvent
| ToolResultEvent
| SummaryEvent
| RollbackEvent
| CompactEvent;
| CompactEvent
| SubagentResultEvent;

export interface SessionSummary extends SessionMetaEvent {
updatedAt: string;
Expand Down
10 changes: 10 additions & 0 deletions packages/codingcode/src/infra/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ export interface ActiveModelConfig {
apiKeyEnv: string;
}

export interface SubagentConfig {
maxBackground: number;
}

export interface AppConfig {
server: {
port: number;
Expand All @@ -29,6 +33,7 @@ export interface AppConfig {
permissionMode: string;
context: ContextConfig;
memory: MemoryConfig;
subagent: SubagentConfig;
}

const DEFAULT_CONTEXT: ContextConfig = {
Expand All @@ -41,6 +46,10 @@ export const DEFAULT_MEMORY: MemoryConfig = {
promptMaxBytes: 8192,
};

export const DEFAULT_SUBAGENT: SubagentConfig = {
maxBackground: 4,
};

export const DEFAULT_CONFIG: AppConfig = {
server: {
port: 8080,
Expand All @@ -51,6 +60,7 @@ export const DEFAULT_CONFIG: AppConfig = {
permissionMode: 'ask',
context: DEFAULT_CONTEXT,
memory: DEFAULT_MEMORY,
subagent: DEFAULT_SUBAGENT,
};

function deepMerge<T extends Record<string, unknown>>(base: T, override: Partial<T>): T {
Expand Down
12 changes: 11 additions & 1 deletion packages/codingcode/src/layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import { MemoryLayer } from './memory/memory.js';
import { AgentLayer } from './agent/agent.js';
import { ToolEnvLayer } from './agent/tool-env.js';
import { SubagentRunnerLayer } from './subagent/subagent.js';
import { SubagentRunRegistryLayer } from './subagent/registry.js';
import { MailboxLayer } from './session/mailbox.js';
import { SchedulerLayer } from './scheduler/scheduler.js';

// base layers
Expand All @@ -36,7 +38,7 @@ const MemoryWithDeps = MemoryLayer.pipe(Layer.provide(LlmLayer));

// agent 直接消费的宽服务集合
const AgentServiceLayers = Layer.mergeAll(
InfraLayer, SessionLayer, ToolExecutorWithDeps, ApprovalWithDeps,
InfraLayer, SessionLayer, MailboxLayer, ToolExecutorWithDeps, ApprovalWithDeps,
ContextWithDeps, MemoryWithDeps, CheckpointLayer, LlmLayer,
);

Expand All @@ -48,17 +50,25 @@ const AgentWithDeps = AgentLayer.pipe(
// subagent runner (depends on agent)
const SubagentWithDeps = SubagentRunnerLayer.pipe(Layer.provide(AgentWithDeps));

// 运行注册表:要 runner 起子代理、要 mailbox 投递终态、要 sink 发 subagent_event 帧。
// 不依赖 SessionLayer —— 它不写盘,写盘由父回合循环在 drain 点做。
const SubagentRunRegistryWithDeps = SubagentRunRegistryLayer.pipe(
Layer.provide(Layer.mergeAll(SubagentWithDeps, MailboxLayer, EventSinkLayer))
);

export const AppLayer = Layer.mergeAll(
InfraLayer,
LlmLayer,
ApprovalWithDeps,
SessionLayer,
MailboxLayer,
ToolExecutorWithDeps,
ContextWithDeps,
MemoryWithDeps,
CheckpointLayer,
AgentWithDeps,
SubagentWithDeps,
SubagentRunRegistryWithDeps,
SchedulerLayer,
EventSinkLayer,
);
Expand Down
3 changes: 3 additions & 0 deletions packages/codingcode/src/server/routes/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { ProfileName } from '../../contracts/types.js';
import { SessionService } from '../../session/port.js';
import { computePaths } from '../../session/paths.js';
import { ContextService } from '../../context/port.js';
import { MailboxService } from '../../session/mailbox.js';
import { estimatePromptTokensFrom } from '../../context/context.js';
import { CheckpointService } from '../../checkpoint/port.js';
import { activeModelId, setGlobalActive } from '../../infra/models.js';
Expand Down Expand Up @@ -143,8 +144,10 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void {
Effect.gen(function* () {
const session = yield* SessionService;
const context = yield* ContextService;
const mailbox = yield* MailboxService;
yield* session.deleteSession(sessionId, cwd);
yield* context.dispose(sessionId);
yield* mailbox.dispose(sessionId);
}) as any
);
return c.json({ ok: true });
Expand Down
48 changes: 48 additions & 0 deletions packages/codingcode/src/session/mailbox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Chunk, Context, Effect, Layer, Queue } from 'effect';
import type { SubagentResultEvent } from '../contracts/session.js';

/** 入站暂存条目。当前唯一生产者是子代理终态 —— 不预留其它变体 */
export type MailboxItem = SubagentResultEvent;

export interface MailboxShape {
/** 投递;该会话还没有队列时自动建 */
offer(sessionId: string, item: MailboxItem): Effect.Effect<void>;
/** 取走当前全部待处理条目(非阻塞,空则返回空数组) */
drain(sessionId: string): Effect.Effect<ReadonlyArray<MailboxItem>>;
/** 会话删除时清空 */
dispose(sessionId: string): Effect.Effect<void>;
}

export class MailboxService extends Context.Tag('Mailbox')<MailboxService, MailboxShape>() {}

/**
* 会话的易失入站队列,与 transcript(持久出站)对称:同一把键 sessionId。
* 全局单例,内部按收件人分区 —— 嵌套委派下 B 的终态进 mailbox[A]。
*/
export const MailboxLayer = Layer.scoped(
MailboxService,
Effect.gen(function* () {
const queues = new Map<string, Queue.Queue<MailboxItem>>();
const queueFor = (sessionId: string) => {
let q = queues.get(sessionId);
if (!q) {
q = Effect.runSync(Queue.unbounded<MailboxItem>());
queues.set(sessionId, q);
}
return q;
};

yield* Effect.addFinalizer(() => Effect.sync(() => queues.clear()));

return {
offer: (sessionId, item) => Queue.offer(queueFor(sessionId), item),
drain: (sessionId) => {
const q = queues.get(sessionId);
return q
? Queue.takeAll(q).pipe(Effect.map(Chunk.toReadonlyArray))
: Effect.succeed<ReadonlyArray<MailboxItem>>([]);
},
dispose: (sessionId) => Effect.sync(() => { queues.delete(sessionId); }),
};
})
);
3 changes: 2 additions & 1 deletion packages/codingcode/src/session/port.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Context } from 'effect';
import type { Effect } from 'effect';
import type { AgentError } from '../core/error.js';
import type { AssistantEvent, RollbackEvent, SessionCreateOptions, SessionEvent, SessionSummary, SessionStoreState, SummaryEvent, ToolResultEvent, UITurn, UserEvent } from '../contracts/session.js';
import type { AssistantEvent, RollbackEvent, SessionCreateOptions, SessionEvent, SessionSummary, SessionStoreState, SubagentResultEvent, SummaryEvent, ToolResultEvent, UITurn, UserEvent } from '../contracts/session.js';
import type { TokenUsage, ProfileName } from '../contracts/types.js';
import type { PermissionMode } from '../contracts/permission.js';

Expand All @@ -17,6 +17,7 @@ export interface SessionShape {
recordSystem(state: SessionStoreState, content: string): Effect.Effect<UserEvent, AgentError>;
recordAssistant(state: SessionStoreState, content: string, toolCalls: AssistantEvent['toolCalls'], usage?: TokenUsage): Effect.Effect<AssistantEvent, AgentError>;
recordToolResult(state: SessionStoreState, toolName: string, toolCallId: string, output: string): Effect.Effect<ToolResultEvent, AgentError>;
recordSubagentResult(state: SessionStoreState, result: { sessionId: string; agentName: string; content: string }): Effect.Effect<SubagentResultEvent, AgentError>;
appendSummary(state: SessionStoreState, summaryText: string, startTurnId: number, endTurnId: number): Effect.Effect<SummaryEvent, AgentError>;
rollbackToTurn(state: SessionStoreState, throughTurnId: number, reason: string): Effect.Effect<RollbackEvent, AgentError>;
readEvents(transcriptPath: string): Effect.Effect<SessionEvent[], AgentError>;
Expand Down
27 changes: 26 additions & 1 deletion packages/codingcode/src/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { join, dirname } from 'path';
import { AgentError } from '../core/error.js';
import { encodeProjectPath } from '../core/path.js';
import { computePaths } from './paths.js';
import type { SessionMetaEvent, UserEvent, AssistantEvent, ToolResultEvent, SummaryEvent, RollbackEvent, SessionEvent, SessionStoreState, SessionSummary, CompactEvent, UITurn } from '../contracts/session.js';
import type { SessionMetaEvent, UserEvent, AssistantEvent, ToolResultEvent, SubagentResultEvent, SummaryEvent, RollbackEvent, SessionEvent, SessionStoreState, SessionSummary, CompactEvent, UITurn } from '../contracts/session.js';
import type { TokenUsage, ProfileName } from '../contracts/types.js';
import type { PermissionMode } from '../contracts/permission.js';
import { SessionService } from './port.js';
Expand Down Expand Up @@ -79,6 +79,7 @@ export function sessionEventsToTurns(events: SessionEvent[]): UITurn[] {
for (const event of events) {
if (event.type === 'session_meta') continue;
if (event.type === 'compact' || event.type === 'rollback') continue;
if (event.type === 'subagent_result') continue; // 结果进模型上下文,不占用户视野

if (event.type === 'summary') {
let turn = turnsMap.get(event.endTurnId);
Expand Down Expand Up @@ -319,6 +320,29 @@ export const SessionLayer = Layer.effect(
: new AgentError('SESSION_IO_ERROR', `Session write failed: ${String(e)}`, e),
});

// 由父回合循环在 drain 点调用:终态先入 mailbox,到这里才落盘。
// 不写 state.usage —— 子代理的用量不算进父会话。
const recordSubagentResult = (
state: SessionStoreState,
result: { sessionId: string; agentName: string; content: string }
): Effect.Effect<SubagentResultEvent, AgentError> =>
Effect.try({
try: () => {
const event: SubagentResultEvent = {
type: 'subagent_result',
sessionId: result.sessionId,
agentName: result.agentName,
content: result.content,
};
appendLine(pathsFromState(state).transcriptPath, event);
return event;
},
catch: (e) =>
e instanceof AgentError
? e
: new AgentError('SESSION_IO_ERROR', `Session write failed: ${String(e)}`, e),
});

const appendSummary = (
state: SessionStoreState,
summaryText: string,
Expand Down Expand Up @@ -435,6 +459,7 @@ export const SessionLayer = Layer.effect(
recordSystem,
recordAssistant,
recordToolResult,
recordSubagentResult,
appendSummary,
rollbackToTurn,

Expand Down
Loading
Loading