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
2 changes: 2 additions & 0 deletions packages/codingcode/src/approval/tool-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const PLAN_TOOL_NAMES: readonly string[] = [
'search_code',
'todo_write',
'todo_read',
'ask_user',
'fetch_url',
'submit_plan',
];
Expand All @@ -21,6 +22,7 @@ export const BUILD_TOOL_NAMES: readonly string[] = [
'web_search',
'todo_write',
'todo_read',
'ask_user',
'spawn_agent',
'wait_agent',
];
Expand Down
19 changes: 19 additions & 0 deletions packages/codingcode/src/askuser/port.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Context } from 'effect';
import type { Effect } from 'effect';

export type AskAnswer =
| { readonly status: 'answered'; readonly options: readonly string[]; readonly custom: string | null }
| { readonly status: 'no_answer' };

export interface AskWaitShape {
/** 挂起等待该调用 id 的用户回答 */
waitForAsk(id: string, sessionId: string): Effect.Effect<AskAnswer>;
/** 用户回答回流;id + sessionId 双键校验,未命中返回 false */
resolveAsk(id: string, sessionId: string, answer: AskAnswer): Effect.Effect<boolean>;
/** 会话收尾时按 sessionId 关掉所有待答问题(fail-closed → no_answer),返回清理条数 */
cancelPendingFor(sessionId: string): Effect.Effect<number>;
/** 挂起方被打断时清掉等待条目(正常完成时条目已被 resolveAsk 删除,此处为幂等 no-op) */
forget(id: string): Effect.Effect<boolean>;
}

export class AskWaitService extends Context.Tag('AskWait')<AskWaitService, AskWaitShape>() {}
51 changes: 51 additions & 0 deletions packages/codingcode/src/askuser/wait.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Layer, Effect, Deferred } from 'effect';
import type { AskAnswer } from './port.js';
import { AskWaitService } from './port.js';

interface PendingEntry {
deferred: Deferred.Deferred<AskAnswer, never>;
sessionId: string;
}

export const AskWaitLayer = Layer.effect(
AskWaitService,
Effect.sync(() => {
const pendingAsks = new Map<string, PendingEntry>();

return {
waitForAsk: (id: string, sessionId: string): Effect.Effect<AskAnswer> =>
Effect.gen(function* () {
const d = yield* Deferred.make<AskAnswer, never>();
pendingAsks.set(id, { deferred: d, sessionId });
return yield* Deferred.await(d);
}),

resolveAsk: (id: string, sessionId: string, answer: AskAnswer): Effect.Effect<boolean> =>
Effect.sync(() => {
const entry = pendingAsks.get(id);
if (!entry || entry.sessionId !== sessionId) return false;
pendingAsks.delete(id);
Deferred.unsafeDone(entry.deferred, Effect.succeed(answer));
return true;
}),

cancelPendingFor: (sessionId: string): Effect.Effect<number> =>
Effect.sync(() => {
let cleared = 0;
for (const [id, entry] of pendingAsks) {
if (entry.sessionId !== sessionId) continue;
pendingAsks.delete(id);
Deferred.unsafeDone(
entry.deferred,
Effect.succeed({ status: 'no_answer' } as AskAnswer)
);
cleared++;
}
return cleared;
}),

forget: (id: string): Effect.Effect<boolean> =>
Effect.sync(() => pendingAsks.delete(id)),
};
})
);
2 changes: 2 additions & 0 deletions packages/codingcode/src/layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ApprovalLayer } from './approval/approval.js';
import { ApprovalWaitLayer } from './approval/wait.js';
import { EventSinkLayer } from './sink/sink.js';
import { TodoLayer } from './todo/todo.js';
import { AskWaitLayer } from './askuser/wait.js';
import { SessionLayer } from './session/session.js';
import { ToolExecutorLayer } from './tools/tools.js';
import { ContextLayer } from './context/context.js';
Expand All @@ -29,6 +30,7 @@ const InfraLayer = Layer.mergeAll(
McpLayer,
EventSinkLayer,
ApprovalWaitLayer,
AskWaitLayer,
TodoLayer
);

Expand Down
2 changes: 2 additions & 0 deletions packages/codingcode/src/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { addMessagesRoutes } from './routes/messages.js';
import { addAssetsRoutes } from './routes/assets.js';
import { addModelsRoutes } from './routes/models.js';
import { addApprovalRoutes } from './routes/approval.js';
import { addAskUserRoutes } from './routes/askuser.js';
import { addSettingsRoutes } from './routes/settings.js';
import { addAutomationsRoutes } from './routes/automations.js';
import { addSubagentsRoutes } from './routes/subagents.js';
Expand All @@ -28,6 +29,7 @@ export const buildRouter = (): HttpRouter.HttpRouter<any, any> =>
addAssetsRoutes,
addModelsRoutes,
addApprovalRoutes,
addAskUserRoutes,
addSettingsRoutes,
addAutomationsRoutes,
addSubagentsRoutes
Expand Down
5 changes: 4 additions & 1 deletion packages/codingcode/src/server/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as HttpServerResponse from '@effect/platform/HttpServerResponse';
import { Effect, Stream } from 'effect';
import { AgentError } from '../util/error.js';
import { ApprovalWaitService } from '../approval/wait-port.js';
import { AskWaitService } from '../askuser/port.js';
import type { FrameBody } from '../sink/types.js';
import { createFrameAssembler, encodeFrame } from './frame-io.js';
import { errorCodeOf, isAppError, type AppError } from './http-error.js';
Expand Down Expand Up @@ -80,16 +81,18 @@ export const readJsonFrom = <T>(web: Request): Effect.Effect<T, AgentError> =>
export const sseResponse = (
frames: Stream.Stream<FrameBody, never>,
opts: { sessionId: string; onDone?: () => void }
): Effect.Effect<HttpServerResponse.HttpServerResponse, never, ApprovalWaitService> =>
): Effect.Effect<HttpServerResponse.HttpServerResponse, never, ApprovalWaitService | AskWaitService> =>
Effect.gen(function* () {
const wait = yield* ApprovalWaitService;
const askWait = yield* AskWaitService;
const assembler = createFrameAssembler({ sessionId: opts.sessionId });
const encoder = new TextEncoder();
const body = frames.pipe(
Stream.map((frame) => encoder.encode(`data: ${encodeFrame(assembler.stamp(frame))}\n\n`)),
Stream.ensuring(
Effect.sync(() => {
Effect.runSync(wait.cancelPendingFor(opts.sessionId));
Effect.runSync(askWait.cancelPendingFor(opts.sessionId));
opts.onDone?.();
})
)
Expand Down
25 changes: 25 additions & 0 deletions packages/codingcode/src/server/routes/askuser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import * as HttpRouter from '@effect/platform/HttpRouter';
import { Effect } from 'effect';
import { AskWaitService, type AskAnswer } from '../../askuser/port.js';
import { json, pathParams, readJsonOrEmpty, type Handler, type Router } from '../handler.js';

/** 严格解析回答 body;无法构成有效回答时 fail-closed 为 no_answer */
function parseAskBody(body: { options?: unknown; custom?: unknown }): AskAnswer {
const custom = typeof body.custom === 'string' ? body.custom : '';
const raw = Array.isArray(body.options) ? body.options : [];
const options = raw.filter((item): item is string => typeof item === 'string' && /^[A-D]$/.test(item));
if (options.length === 0 && custom.trim() === '') return { status: 'no_answer' };
return { status: 'answered', options, custom: custom.trim() || null };
}

const resolve: Handler = Effect.gen(function* () {
const { id, sessionId } = yield* pathParams;
const body = yield* readJsonOrEmpty<{ options?: unknown; custom?: unknown }>();

const svc = yield* AskWaitService;
const resolved = yield* svc.resolveAsk(id ?? '', sessionId ?? '', parseAskBody(body));
return json({ ok: resolved });
});

export const addAskUserRoutes = (router: Router): Router =>
router.pipe(HttpRouter.post('/api/sessions/:sessionId/askuser/:id', resolve));
7 changes: 7 additions & 0 deletions packages/codingcode/src/sink/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ export type RuntimeEvent =
readonly tool: string;
readonly args: Readonly<Record<string, unknown>>;
}
| {
readonly type: 'ask_user';
readonly id: string;
readonly question: string;
readonly questiontype: 'single' | 'multiple' | 'text';
readonly options?: readonly string[];
}
| {
readonly type: 'subagent_event';
readonly sessionId: string;
Expand Down
2 changes: 2 additions & 0 deletions packages/codingcode/src/tools/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { webFetchTool } from './domains/web/fetch.js';
import { webSearchTool } from './domains/web/search.js';
import { todoWriteTool } from './domains/self/todo-write.js';
import { todoReadTool } from './domains/self/todo-read.js';
import { askUserTool } from './domains/self/ask-user.js';
import { spawnAgentTool } from './domains/subagent/spawn.js';
import { waitAgentTool } from './domains/subagent/wait.js';
import { submitPlanTool } from './domains/subagent/submit-plan.js';
Expand All @@ -29,6 +30,7 @@ const ALL_TOOLS: ToolDefinition<any>[] = [
webSearchTool,
todoWriteTool,
todoReadTool,
askUserTool,
spawnAgentTool,
waitAgentTool,
submitPlanTool,
Expand Down
111 changes: 111 additions & 0 deletions packages/codingcode/src/tools/domains/self/ask-user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { z } from 'zod';
import { Effect } from 'effect';
import type { ToolDefinition } from '../../types.js';
import { AgentError } from '../../../util/error.js';
import { EventSinkService } from '../../../sink/port.js';
import { AskWaitService, type AskAnswer } from '../../../askuser/port.js';

type AskQuestionType = 'single' | 'multiple' | 'text';

const askUserSchema = z.object({
questiontype: z.enum(['single', 'multiple', 'text']),
question: z.string().min(1).max(500),
options: z.array(z.string().min(1).max(200)).max(4).optional(),
});

const NO_ANSWER_OUTPUT = '用户未回答(用户取消或连接中断)';

function trimOrNull(v: string | null | undefined): string | null {
const t = v?.trim();
return t ? t : null;
}

function formatOutput(
questiontype: AskQuestionType,
options: readonly string[],
answer: AskAnswer
): string | undefined {
if (answer.status === 'no_answer') return undefined;

if (questiontype === 'text') {
const text = trimOrNull(answer.custom);
return text ? `用户回答:${text}` : undefined;
}

const picked = answer.options
.filter((letter) => /^[A-D]$/.test(letter))
.map((letter) => {
const index = letter.charCodeAt(0) - 65;
return options[index] !== undefined ? `${letter}:${options[index]}` : null;
})
.filter((item): item is string => item !== null);
const custom = trimOrNull(answer.custom);
if (picked.length === 0 && custom === null) return undefined;

const items = picked.length > 0 ? picked : [`自定义:${custom}`];
let out = `用户选择了:[${items.map((item) => JSON.stringify(item)).join(',')}]`;
if (picked.length > 0 && custom !== null) out += `;用户补充输入:${custom}`;
return out;
}

export const askUserTool: ToolDefinition<AskWaitService | EventSinkService> = {
name: 'ask_user',
concurrencySafe: true,
description:
'Ask the end user a structured question and block until they answer. Use when missing information ' +
'blocks the correct next action. questiontype: single (radio), multiple (checkbox), text (free input). ' +
'options: 2-4 choice texts for single/multiple, ignored for text; letters A-D are assigned by order ' +
'automatically. The user may also type a custom answer besides the options. Returns e.g. ' +
'用户选择了:["A:红色"] or 用户回答:.... Multiple ask_user calls may be batched, each blocks on its ' +
'own answer. If it returns 用户未回答, proceed with reasonable assumptions, state them explicitly, ' +
'and re-ask at most once.',
parameters: askUserSchema,
execute: (args, ctx) =>
Effect.gen(function* () {
const { questiontype, question, options } = args as {
questiontype: AskQuestionType;
question: string;
options?: string[];
};

if (questiontype !== 'text' && (!options || options.length < 2)) {
return yield* Effect.fail(
new AgentError('TOOL_EXECUTION_FAILED', 'ask_user choice questions require 2-4 options')
);
}

const sessionId = ctx?.sessionId;
if (!sessionId) {
return yield* Effect.fail(
new AgentError('TOOL_EXECUTION_FAILED', 'ask_user requires sessionId')
);
}
const callId = ctx?.callId;
if (!callId) {
return yield* Effect.fail(
new AgentError('TOOL_EXECUTION_FAILED', 'ask_user requires callId')
);
}

const waitSvc = yield* AskWaitService;
const sink = yield* EventSinkService;

yield* sink.emit(sessionId, {
family: 'event',
event: {
type: 'ask_user',
id: callId,
question,
questiontype,
options: questiontype === 'text' ? undefined : options,
},
});

const answer = yield* waitSvc.waitForAsk(callId, sessionId).pipe(
Effect.ensuring(waitSvc.forget(callId))
);

const choices = questiontype === 'text' ? [] : (options ?? []);
return formatOutput(questiontype, choices, answer) ?? NO_ANSWER_OUTPUT;
}),
};
2 changes: 1 addition & 1 deletion packages/codingcode/src/tools/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { createToolCatalog } from './catalog.js';
type Lookup = (name: string) => ToolRunner | undefined;

function toToolCtx(opts: ToolExecCall): ToolExecCtx {
const { turnId: _turnId, callId: _callId, ...ctx } = opts;
const { turnId: _turnId, ...ctx } = opts;
return ctx;
}

Expand Down
4 changes: 3 additions & 1 deletion packages/codingcode/src/tools/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export interface ToolDefinition<R = never> {
export interface ToolExecCtx {
signal?: AbortSignal;
sessionId?: string;
/** 本次调用的 tool_call id(由 execSingle 以 tc.id 注入);ask_user 用作等待键 */
callId?: string;
projectPath?: string;
activeProfile?: ProfileName;
model: string;
Expand All @@ -37,7 +39,7 @@ export interface ToolRunner {

export type ToolLookup = (name: string) => ToolRunner | undefined;

export type ToolExecOpts = Omit<ToolExecCtx, 'sessionId'> & {
export type ToolExecOpts = Omit<ToolExecCtx, 'sessionId' | 'callId'> & {
turnId?: number;
onBeforeMutation?: Effect.Effect<void>;
};
Expand Down
Loading
Loading