sync: update from private repo (c764df2f)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-06-29 01:32:25 +00:00
parent b857c33ef6
commit a6d1879d63
75 changed files with 3874 additions and 334 deletions
+11
View File
@@ -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. */
+2
View File
@@ -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 = {
+36
View File
@@ -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);
});
});
+35 -2
View File
@@ -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) {
+8
View File
@@ -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);
+10 -1
View File
@@ -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
+19
View File
@@ -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
View File
@@ -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);