This commit is contained in:
@@ -115,6 +115,11 @@ export interface ToolContext {
|
||||
mcpQuotaState?: { files: number; bytes: number };
|
||||
runIsolatedLlm?: (messages: Message[]) => Promise<string>;
|
||||
spawnSubTask?: (params: { title: string; instruction: string; piece?: string }) => Promise<{ jobId: string; subtaskIndex: number; workspacePath: string }>;
|
||||
/**
|
||||
* WaitSubTask: このジョブに未完了(非 terminal)の子サブタスクが残っているか。
|
||||
* worker が getSubJobs ベースで実装する。未配線(サブタスク非対応経路)なら undefined。
|
||||
*/
|
||||
hasPendingSubtasks?: () => Promise<boolean>;
|
||||
/**
|
||||
* 相 B: 直列・文脈節約型サブエージェント実行。delegate ツールから呼ぶ。
|
||||
* 親ループ内で同期的にサブエージェントを完了まで走らせ最終結果のみ返す。
|
||||
@@ -212,6 +217,12 @@ export interface ToolContext {
|
||||
* for inline approval of the named tool. Read + reset by agent-loop.
|
||||
*/
|
||||
pendingToolApproval?: { toolName: string } | null;
|
||||
/**
|
||||
* Set by WaitSubTask to signal the movement loop to park (waiting_subtasks)
|
||||
* until this job's spawned children finish. Read + reset by agent-loop, which
|
||||
* then resumes the SAME movement. Mirrors pendingToolApproval.
|
||||
*/
|
||||
pendingSubtaskWait?: boolean;
|
||||
/** Per-task option: when true, MCP tools are not loaded/dispatched. */
|
||||
mcpDisabled?: boolean;
|
||||
/** Per-task option: when true, skill index is not injected into the system prompt. */
|
||||
|
||||
@@ -100,6 +100,8 @@ const TOOL_DOC_ALIASES: Record<string, string> = {
|
||||
appbridge: 'workspace-apps',
|
||||
// TestWorkspaceApp (testworkspaceapp.md)
|
||||
testworkspaceapp: 'testworkspaceapp',
|
||||
// orchestration.ts: WaitSubTask は spawnsubtask.md にまとめる
|
||||
waitsubtask: 'spawnsubtask',
|
||||
};
|
||||
|
||||
const READ_TOOL_DOC_DEF: ToolDef = {
|
||||
|
||||
@@ -151,3 +151,39 @@ describe('delegate tool', () => {
|
||||
expect(res?.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WaitSubTask tool', () => {
|
||||
it('is registered in TOOL_DEFS as an argless tool', () => {
|
||||
const def = TOOL_DEFS['WaitSubTask'];
|
||||
expect(def?.function.name).toBe('WaitSubTask');
|
||||
expect(def?.function.parameters?.['properties']).toEqual({});
|
||||
expect(def?.function.parameters?.['required']).toEqual([]);
|
||||
});
|
||||
|
||||
it('errors when the context cannot wait for subtasks (no hasPendingSubtasks)', async () => {
|
||||
const ctx = makeCtx({ hasPendingSubtasks: undefined } as Partial<ToolContext>);
|
||||
const res = await executeTool('WaitSubTask', {}, ctx);
|
||||
expect(res?.isError).toBe(true);
|
||||
expect(res?.output).toContain('使用できません');
|
||||
expect(ctx.pendingSubtaskWait).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not park when there are no pending subtasks', async () => {
|
||||
const hasPendingSubtasks = vi.fn().mockResolvedValue(false);
|
||||
const ctx = makeCtx({ hasPendingSubtasks } as Partial<ToolContext>);
|
||||
const res = await executeTool('WaitSubTask', {}, ctx);
|
||||
expect(hasPendingSubtasks).toHaveBeenCalledTimes(1);
|
||||
expect(res?.isError).toBe(false);
|
||||
expect(res?.output).toContain('未完了サブタスクはありません');
|
||||
expect(ctx.pendingSubtaskWait).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sets the park flag when subtasks are still pending', async () => {
|
||||
const hasPendingSubtasks = vi.fn().mockResolvedValue(true);
|
||||
const ctx = makeCtx({ hasPendingSubtasks } as Partial<ToolContext>);
|
||||
const res = await executeTool('WaitSubTask', {}, ctx);
|
||||
expect(hasPendingSubtasks).toHaveBeenCalledTimes(1);
|
||||
expect(res?.isError).toBe(false);
|
||||
expect(ctx.pendingSubtaskWait).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,13 +37,25 @@ export const TOOL_DEFS: Record<string, ToolDef> = {
|
||||
},
|
||||
piece: {
|
||||
type: 'string',
|
||||
description: '使用するピース名(general, research, brainstorming, orchestrated, data-process, office-process)。デフォルトは general。',
|
||||
description: '使用するピース名(general, research, brainstorming, data-process, office-process)。デフォルトは general。',
|
||||
},
|
||||
},
|
||||
required: ['title', 'instruction'],
|
||||
},
|
||||
},
|
||||
},
|
||||
WaitSubTask: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'WaitSubTask',
|
||||
description: 'SpawnSubTask で起動した全サブタスクの完了を待ちます(引数なし)。並列起動したあとに1回呼ぶと全完了まで停車し、完了後に subtasks/ へ結果が揃った状態で同じステップに再開します。詳細は ReadToolDoc({ name: "SpawnSubTask" })。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export async function executeTool(
|
||||
@@ -51,7 +63,28 @@ export async function executeTool(
|
||||
input: Record<string, unknown>,
|
||||
ctx: ToolContext,
|
||||
): Promise<ToolResult | null> {
|
||||
if (name !== 'SpawnSubTask' && name !== 'delegate') return null;
|
||||
if (name !== 'SpawnSubTask' && name !== 'delegate' && name !== 'WaitSubTask') return null;
|
||||
|
||||
if (name === 'WaitSubTask') {
|
||||
if (!ctx.hasPendingSubtasks) {
|
||||
return { output: 'WaitSubTask はこのコンテキストでは使用できません(サブタスク非対応の実行経路)。', isError: true };
|
||||
}
|
||||
const hasPending = await ctx.hasPendingSubtasks();
|
||||
if (!hasPending) {
|
||||
return {
|
||||
output: '待機対象の未完了サブタスクはありません。subtasks/ 配下の各 result.md を読んで統合し、回答を complete してください。SpawnSubTask を再度呼ぶ必要はありません。',
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
// 制御フロー(park シグナル): agent-loop がこのフラグを見て movement を
|
||||
// WAIT_SUBTASKS で停車させ、同じ movement に再開する。ツール内で同期ブロックは
|
||||
// しない(ワーカー解放を保つ=デッドロック回避)。
|
||||
ctx.pendingSubtaskWait = true;
|
||||
return {
|
||||
output: 'サブタスクの完了を待機します。全完了後、同じステップに再開し、subtasks/ の結果を読めます。',
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (name === 'delegate') {
|
||||
if (!ctx.runDelegate) {
|
||||
|
||||
@@ -70,6 +70,14 @@ describe('tool-categories', () => {
|
||||
expect(SENSITIVE_TOOLS.has('Read')).toBe(false);
|
||||
});
|
||||
|
||||
it('marks SpawnSubTask as a sensitive (opt-in) tool but not WaitSubTask', () => {
|
||||
// D1: SpawnSubTask is opt-in (parallel separate-job decomposition). delegate is
|
||||
// the default-on serial path. WaitSubTask stays default-on (no-op without children).
|
||||
expect(SENSITIVE_TOOLS.has('SpawnSubTask')).toBe(true);
|
||||
expect(SENSITIVE_TOOLS.has('WaitSubTask')).toBe(false);
|
||||
expect(SENSITIVE_TOOLS.has('delegate')).toBe(false);
|
||||
});
|
||||
|
||||
it('exports META_TOOLS with expected entries', () => {
|
||||
expect(META_TOOLS).toBeInstanceOf(Set);
|
||||
expect(META_TOOLS.has('ReadToolDoc')).toBe(true);
|
||||
|
||||
@@ -56,8 +56,17 @@ export const SENSITIVE_CATEGORIES: ReadonlySet<string> = new Set(['ssh', 'browse
|
||||
/**
|
||||
* Individual tools that require elevated scrutiny regardless of their
|
||||
* category (e.g. Bash even though its category is 'core').
|
||||
*
|
||||
* SpawnSubTask: parallel separate-job decomposition. Each spawned child takes a
|
||||
* worker + its own workspace + a GPU slot, so a burst can starve other queued
|
||||
* work. Built-in pieces now decompose via the in-process serial `delegate` tool
|
||||
* (orchestration category, default-on), so SpawnSubTask is opt-in only — a
|
||||
* workspace must explicitly enable it when it genuinely needs parallel subtasks.
|
||||
* The code (SpawnSubTask / WAIT_SUBTASKS / WaitSubTask) stays; only the default
|
||||
* exposure flips. WaitSubTask stays in the default-on 'orchestration' category:
|
||||
* it's a no-op when there are no children, so it's harmless without SpawnSubTask.
|
||||
*/
|
||||
export const SENSITIVE_TOOLS: ReadonlySet<string> = new Set(['Bash']);
|
||||
export const SENSITIVE_TOOLS: ReadonlySet<string> = new Set(['Bash', 'SpawnSubTask']);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Module specs — canonical category → module specifier mapping
|
||||
|
||||
@@ -53,6 +53,14 @@ describe('sanitizeQuery', () => {
|
||||
it('preserves public IPs', () => {
|
||||
expect(sanitizeQuery('query 8.8.8.8 dns', {})).toBe('query 8.8.8.8 dns');
|
||||
});
|
||||
it('returns null for missing / non-string query instead of throwing', () => {
|
||||
// 退行したモデルが query なしの WebSearch を吐くと undefined が渡ってくる
|
||||
expect(sanitizeQuery(undefined as unknown as string, {})).toBeNull();
|
||||
expect(sanitizeQuery(null as unknown as string, {})).toBeNull();
|
||||
expect(sanitizeQuery(123 as unknown as string, {})).toBeNull();
|
||||
expect(sanitizeQuery('', {})).toBeNull();
|
||||
expect(sanitizeQuery(' ', {})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('web tools', () => {
|
||||
@@ -148,6 +156,17 @@ describe('web tools', () => {
|
||||
expect(fs.existsSync(path.join(workspacePath, 'logs', 'webfetch-screenshots'))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns a friendly error (not a crash) when WebSearch query is missing', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
|
||||
// 引数欠落のツール呼び出し(退行モデル由来)でジョブ全体を巻き添えにしない
|
||||
const result = await executeTool('WebSearch', {}, makeContext(workspacePath));
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('query');
|
||||
});
|
||||
|
||||
it('records invalid URL attempts before fetch', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
|
||||
|
||||
+16
-2
@@ -108,7 +108,16 @@ const INTERNAL_DOMAIN_PATTERN = /\b[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\.(?:
|
||||
* 検索クエリから機密情報を除去する。
|
||||
* @returns サニタイズ済みクエリ。空になった場合は null を返す。
|
||||
*/
|
||||
export function sanitizeQuery(query: string, config: SearchFilterConfig): string | null {
|
||||
export function sanitizeQuery(
|
||||
query: string | null | undefined,
|
||||
config: SearchFilterConfig,
|
||||
): string | null {
|
||||
// 退行したモデルが query 引数なし(または非文字列)の WebSearch を吐くことがある。
|
||||
// ここで弾かないと undefined.replace() でジョブ全体がクラッシュする。
|
||||
if (typeof query !== 'string' || query.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const autoBlock = {
|
||||
privateIp: config.autoBlock?.privateIp !== false,
|
||||
internalDomain: config.autoBlock?.internalDomain !== false,
|
||||
@@ -630,10 +639,15 @@ async function executeWebSearch(
|
||||
input: Record<string, unknown>,
|
||||
ctx: ToolContext,
|
||||
): Promise<ToolResult> {
|
||||
const rawQuery = input['query'] as string;
|
||||
const rawQuery = input['query'];
|
||||
const rawLimit = typeof input['limit'] === 'number' ? input['limit'] : 5;
|
||||
const limit = Math.min(Math.max(1, rawLimit), 20);
|
||||
|
||||
// query 引数の欠落(退行モデルの不正なツール呼び出し)はクラッシュさせず親切に返す
|
||||
if (typeof rawQuery !== 'string' || rawQuery.trim().length === 0) {
|
||||
return { output: 'WebSearch には query(検索文字列)を指定してください。', isError: true };
|
||||
}
|
||||
|
||||
// 検索クエリの機密情報フィルタリング
|
||||
const filterConfig = ctx.searchFilter ?? ctx.toolsConfig?.searchFilter ?? {};
|
||||
const sanitized = sanitizeQuery(rawQuery, filterConfig);
|
||||
|
||||
Reference in New Issue
Block a user