@ludi-uni/ludi-agent-kit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +55 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/adapters/codex/README.md +24 -0
- package/adapters/codex/skill-metadata/visual-verification/agents/openai.yaml +7 -0
- package/adapters/pi/README.md +88 -0
- package/adapters/pi/browser/agent-browser.mjs +193 -0
- package/adapters/pi/lib/invoke.mjs +55 -0
- package/adapters/pi/lib/list-models.mjs +29 -0
- package/adapters/pi/lib/settings-proposal.mjs +34 -0
- package/adapters/pi/lib/subagent.mjs +175 -0
- package/adapters/pi/loop-guard/index.js +51 -0
- package/adapters/pi/maintenance-policy.json +36 -0
- package/adapters/pi/mcp.template.json +4 -0
- package/adapters/pi/model-catalog.json +97 -0
- package/adapters/pi/models.json +13 -0
- package/adapters/pi/models.local.example.json +14 -0
- package/adapters/pi/orchestrator-ext/command.mjs +14 -0
- package/adapters/pi/orchestrator-ext/index.js +150 -0
- package/adapters/pi/settings.template.json +7 -0
- package/adapters/pi/shell-gate/index.js +70 -0
- package/adapters/pi/sync-pi.ps1 +137 -0
- package/agents/README.md +26 -0
- package/agents/browser.md +64 -0
- package/agents/coder.md +31 -0
- package/agents/orchestrator.md +37 -0
- package/agents/reviewer.md +32 -0
- package/agents/scout.md +35 -0
- package/agents/tester.md +28 -0
- package/agents/visual.md +28 -0
- package/context-pack/SPEC.md +101 -0
- package/context-pack/context-pack.schema.json +79 -0
- package/context-pack/examples/example-fix.md +44 -0
- package/docs/architecture.md +55 -0
- package/docs/migration-from-codex-setting.md +44 -0
- package/docs/model-maintenance.md +401 -0
- package/docs/orchestrator.md +155 -0
- package/docs/phase2-report.md +39 -0
- package/docs/roadmap.md +27 -0
- package/docs/third-party.md +15 -0
- package/lib/agents.mjs +79 -0
- package/lib/context-pack.mjs +215 -0
- package/lib/job.mjs +312 -0
- package/lib/language-policy.mjs +27 -0
- package/lib/maintenance-exec.mjs +377 -0
- package/lib/maintenance-runner.mjs +266 -0
- package/lib/maintenance.mjs +422 -0
- package/lib/normalize.mjs +101 -0
- package/lib/observe/differ.mjs +185 -0
- package/lib/observe/observation.mjs +147 -0
- package/lib/observe/observers.mjs +134 -0
- package/lib/observe/sources.mjs +154 -0
- package/lib/orchestrator/activity.mjs +249 -0
- package/lib/orchestrator/api.mjs +151 -0
- package/lib/orchestrator/contract.mjs +68 -0
- package/lib/orchestrator/escalation.mjs +84 -0
- package/lib/orchestrator/evaluator.mjs +92 -0
- package/lib/orchestrator/failures.mjs +88 -0
- package/lib/orchestrator/health.mjs +53 -0
- package/lib/orchestrator/orchestrator.mjs +483 -0
- package/lib/orchestrator/permissions.mjs +64 -0
- package/lib/orchestrator/planner.mjs +194 -0
- package/lib/orchestrator/policy.mjs +134 -0
- package/lib/orchestrator/router.mjs +45 -0
- package/lib/orchestrator/runner.mjs +278 -0
- package/lib/orchestrator/shell-policy.mjs +52 -0
- package/lib/orchestrator/store.mjs +581 -0
- package/lib/orchestrator/task-store.mjs +79 -0
- package/lib/orchestrator/turn-budget.mjs +63 -0
- package/lib/orchestrator/worktree.mjs +72 -0
- package/lib/pipeline.mjs +279 -0
- package/lib/registry.mjs +63 -0
- package/lib/resolve.mjs +35 -0
- package/lib/routing.mjs +137 -0
- package/lib/telemetry.mjs +222 -0
- package/mcp/README.md +11 -0
- package/mcp/servers.json +13 -0
- package/orchestration/decision-policy.json +66 -0
- package/package.json +56 -0
- package/routing/README.md +24 -0
- package/routing/routing.json +81 -0
- package/routing/routing.schema.json +66 -0
- package/rules/README.md +10 -0
- package/rules/common.md +52 -0
- package/rules/loop-prevention.md +15 -0
- package/rules/repo-local.md +6 -0
- package/scripts/check-environment.ps1 +22 -0
- package/scripts/context-pack.mjs +17 -0
- package/scripts/e2e-investigate-repro.mjs +66 -0
- package/scripts/model-maintenance-job.mjs +59 -0
- package/scripts/observe-models.mjs +97 -0
- package/scripts/orchestrate.mjs +137 -0
- package/scripts/reevaluate-models.mjs +95 -0
- package/scripts/report-model-maintenance.mjs +70 -0
- package/scripts/resolve-capabilities.mjs +39 -0
- package/scripts/run-pipeline.mjs +56 -0
- package/scripts/sync-agents-md.ps1 +10 -0
- package/scripts/validate.mjs +71 -0
- package/skills/README.md +14 -0
- package/skills/pi-workflow/SKILL.md +26 -0
- package/skills/pi-workflow/references/code-investigation-and-fix.md +16 -0
- package/skills/pi-workflow/references/research.md +14 -0
- package/skills/pi-workflow/references/review.md +11 -0
- package/skills/pi-workflow/references/visual-work.md +14 -0
- package/skills/project-management/SKILL.md +106 -0
- package/skills/project-management/references/operations.md +52 -0
- package/skills/visual-verification/SKILL.md +88 -0
- package/skills/visual-verification/scripts/analyze-speech.ps1 +346 -0
- package/skills/visual-verification/scripts/backends/whisperx_backend.py +234 -0
- package/skills/visual-verification/scripts/common.ps1 +387 -0
- package/skills/visual-verification/scripts/contact-sheet.ps1 +121 -0
- package/skills/visual-verification/scripts/desktop-discover.ps1 +45 -0
- package/skills/visual-verification/scripts/desktop-inspect.ps1 +67 -0
- package/skills/visual-verification/scripts/desktop-record.ps1 +97 -0
- package/skills/visual-verification/scripts/desktop-screenshot.ps1 +65 -0
- package/skills/visual-verification/scripts/evaluate-sync.ps1 +249 -0
- package/skills/visual-verification/scripts/extract-frames.ps1 +79 -0
- package/skills/visual-verification/scripts/inspect-media.ps1 +138 -0
- package/skills/visual-verification/scripts/record-av.ps1 +102 -0
- package/skills/visual-verification/scripts/record.ps1 +72 -0
- package/skills/visual-verification/scripts/screenshot.ps1 +44 -0
- package/skills/visual-verification/scripts/waveform.ps1 +450 -0
- package/skills/visual-verification/scripts/winapp-common.ps1 +465 -0
- package/tests/activity.test.mjs +252 -0
- package/tests/attempt-budget.test.mjs +102 -0
- package/tests/browser.test.mjs +121 -0
- package/tests/context-pack.test.mjs +98 -0
- package/tests/dirty-gate.test.mjs +211 -0
- package/tests/e2e-browser.mjs +66 -0
- package/tests/e2e-real-orchestrator-resume.mjs +101 -0
- package/tests/e2e-real-orchestrator.mjs +41 -0
- package/tests/e2e-real-pi.mjs +27 -0
- package/tests/e2e-real-tool-orchestrator.mjs +66 -0
- package/tests/fixtures/browser-page/index.html +20 -0
- package/tests/fixtures/maintenance/availability.txt +5 -0
- package/tests/fixtures/maintenance/catalog.json +74 -0
- package/tests/fixtures/maintenance/events.json +13 -0
- package/tests/fixtures/math-repo/README.md +3 -0
- package/tests/fixtures/math-repo/package.json +7 -0
- package/tests/fixtures/math-repo/src/math.js +11 -0
- package/tests/fixtures/math-repo/test/math.test.js +7 -0
- package/tests/fixtures/observe/announcements.json +8 -0
- package/tests/fixtures/orch-concurrent-child.mjs +44 -0
- package/tests/fixtures/orch-persist-child.mjs +61 -0
- package/tests/job.test.mjs +230 -0
- package/tests/kit.test.mjs +79 -0
- package/tests/language-policy.test.mjs +93 -0
- package/tests/loop-guard.test.mjs +60 -0
- package/tests/maintenance-exec.test.mjs +218 -0
- package/tests/maintenance-runner.test.mjs +222 -0
- package/tests/maintenance.test.mjs +195 -0
- package/tests/observe.test.mjs +283 -0
- package/tests/observer-registry.test.mjs +157 -0
- package/tests/orchestrator-cleanup.test.mjs +358 -0
- package/tests/orchestrator-command.test.mjs +14 -0
- package/tests/orchestrator-persist.test.mjs +375 -0
- package/tests/orchestrator-tools.test.mjs +215 -0
- package/tests/orchestrator.test.mjs +396 -0
- package/tests/package.test.mjs +37 -0
- package/tests/pipeline.test.mjs +239 -0
- package/tests/planner-classification.test.mjs +81 -0
- package/tests/planner-split.test.mjs +67 -0
- package/tests/qoder-observer.test.mjs +266 -0
- package/tests/reassign-progression.test.mjs +104 -0
- package/tests/retry-escalation.test.mjs +120 -0
- package/tests/routing.test.mjs +110 -0
- package/tests/sqlite-concurrency.test.mjs +178 -0
- package/tests/task-global-e2e.test.mjs +63 -0
- package/tests/task-global-failed.test.mjs +134 -0
- package/tests/telemetry.test.mjs +173 -0
- package/tests/test-sync-pi.ps1 +56 -0
- package/tests/turn-budget.test.mjs +106 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Tool access is separate from routing capability. `tools:` on an agent remains the pi tool-name
|
|
2
|
+
// list; `access` says what the orchestrator may actually grant.
|
|
3
|
+
export const EXECUTION_MODES = ['oneshot', 'subagent', 'pipeline'];
|
|
4
|
+
const FILESYSTEM = new Set(['read', 'read-write']);
|
|
5
|
+
const SHELL = new Set(['false', 'limited', 'true']);
|
|
6
|
+
const GIT = new Set(['none', 'read']);
|
|
7
|
+
|
|
8
|
+
export function accessOf(agent) {
|
|
9
|
+
const raw = agent?.meta?.access;
|
|
10
|
+
if (raw && typeof raw === 'object') {
|
|
11
|
+
return {
|
|
12
|
+
filesystem: FILESYSTEM.has(raw.filesystem) ? raw.filesystem : 'read',
|
|
13
|
+
shell: SHELL.has(String(raw.shell)) ? String(raw.shell) : 'false',
|
|
14
|
+
git: GIT.has(raw.git) ? raw.git : 'none',
|
|
15
|
+
network: raw.network === true,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
const names = String(agent?.meta?.tools ?? '').split(',').map(s => s.trim()).filter(Boolean);
|
|
19
|
+
const write = names.some(n => n === 'edit' || n === 'write');
|
|
20
|
+
const shell = names.some(n => n === 'powershell' || n === 'bash');
|
|
21
|
+
return { filesystem: write ? 'read-write' : 'read', shell: shell ? 'limited' : 'false', git: 'read', network: false };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function validateAccess(access, name) {
|
|
25
|
+
const errors = [];
|
|
26
|
+
if (!access || typeof access !== 'object') return errors;
|
|
27
|
+
if (access.filesystem !== undefined && !FILESYSTEM.has(access.filesystem)) errors.push(`agent ${name}: access.filesystem must be read|read-write`);
|
|
28
|
+
if (access.shell !== undefined && !SHELL.has(String(access.shell))) errors.push(`agent ${name}: access.shell must be false|limited|true`);
|
|
29
|
+
if (access.git !== undefined && !GIT.has(access.git)) errors.push(`agent ${name}: access.git must be none|read`);
|
|
30
|
+
if (access.network !== undefined && typeof access.network !== 'boolean') errors.push(`agent ${name}: access.network must be boolean`);
|
|
31
|
+
return errors;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** pi --tools allowlist. Raw bash/powershell are never granted; shell goes through ludi_exec. */
|
|
35
|
+
export function piToolsForAccess(access) {
|
|
36
|
+
const tools = ['read', 'grep', 'find', 'ls'];
|
|
37
|
+
if (access.filesystem === 'read-write') tools.push('edit', 'write');
|
|
38
|
+
if (access.shell !== 'false') tools.push('ludi_exec');
|
|
39
|
+
return tools;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Mode named by the task or the agent file, before runner availability is applied. */
|
|
43
|
+
export function declaredMode(agent, task, { pipelineAgents = [] } = {}) {
|
|
44
|
+
if (pipelineAgents.includes(agent?.meta?.name) || task?.executionMode === 'pipeline') return 'pipeline';
|
|
45
|
+
const requested = task?.executionMode ?? agent?.meta?.execution?.preferred_mode ?? agent?.meta?.execution;
|
|
46
|
+
if (EXECUTION_MODES.includes(requested)) return requested;
|
|
47
|
+
if (['coder', 'tester'].includes(agent?.meta?.name)) return 'subagent';
|
|
48
|
+
return 'oneshot';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function resolveExecutionMode(agent, task, { pipelineAgents = [], hasSubagent = false } = {}) {
|
|
52
|
+
if (pipelineAgents.includes(agent?.meta?.name)) return 'pipeline';
|
|
53
|
+
const requested = task?.executionMode ?? agent?.meta?.execution?.preferred_mode ?? agent?.meta?.execution;
|
|
54
|
+
if (requested === 'pipeline') return 'pipeline';
|
|
55
|
+
if (requested === 'oneshot') return 'oneshot';
|
|
56
|
+
if (requested === 'subagent') return hasSubagent ? 'subagent' : 'oneshot';
|
|
57
|
+
if (hasSubagent && ['coder', 'tester'].includes(agent?.meta?.name)) return 'subagent';
|
|
58
|
+
return 'oneshot';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function workspaceOf(task, repoRoot) {
|
|
62
|
+
const path = task?.workspace?.path ?? repoRoot ?? null;
|
|
63
|
+
return { path, repository: task?.workspace?.repository ?? repoRoot ?? path };
|
|
64
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Planner: high-level request -> task specs. Two strategies, same output shape:
|
|
2
|
+
// rules - deterministic keyword template (no model call; default, used by dry-run)
|
|
3
|
+
// model - the orchestrator agent returns a JSON plan; invalid output falls back to rules
|
|
4
|
+
import { withEscalation } from '../pipeline.mjs';
|
|
5
|
+
import { routeTask, workerAgents, ORCHESTRATOR_AGENT } from './router.mjs';
|
|
6
|
+
import { findCycle } from './task-store.mjs';
|
|
7
|
+
|
|
8
|
+
const RE = {
|
|
9
|
+
implement: /\b(implement|fix|add|create|build|refactor|update|change|migrate|write|support)\b|進め|実装|修正|追加|作成|対応|改善|変更|移行|作る/i,
|
|
10
|
+
review: /\b(review|audit)\b|レビュー|監査/i,
|
|
11
|
+
investigate: /\b(investigate|explain|analy[sz]e|research|survey|find out)\b|調査|説明|分析|調べ/i,
|
|
12
|
+
visual: /\b(screenshot|visual|ui|image|animation|video|render(ing)?)\b|画面|見た目|画像|アニメ|動画|描画/i,
|
|
13
|
+
browser: /\b(browser|web ?page|localhost|url)\b|ブラウザ|webページ|ウェブ/i,
|
|
14
|
+
history: /\b(commit history|git log|past commits|previous commits|commit(s)? histor\w*|changelog|previous changes)\b|コミット履歴|変更履歴|過去のコミット/i,
|
|
15
|
+
// ASCII words need \b on both sides ("build", "guide", "linux", "requires" must
|
|
16
|
+
// not match); Japanese has no word boundary, so 画面/好み stand alone.
|
|
17
|
+
ui: /\b(ui|ux|interface|screen|design|layout|preference)\b|画面|好み/i,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Context-aware history detection: bare "コミット"/"commit" alone is not a history
|
|
21
|
+
// investigation ("このコミットを修正して", "add a pre-commit hook" are single-commit
|
|
22
|
+
// or tooling work). It is one only when combined with ANALYSIS context (infer
|
|
23
|
+
// preferences / style / patterns / trends). Everyday words ("from", "instructions",
|
|
24
|
+
// "から") are deliberately NOT context.
|
|
25
|
+
const HISTORY_CONTEXT = {
|
|
26
|
+
ja: /コミット(?!を(修正|直し|適用|打ち|作|追加|戻|取り消))/i,
|
|
27
|
+
jaContext: /好み|推測|推定|傾向|履歴|分析|スタイル|方針|パターン/i,
|
|
28
|
+
en: /(?<![\w-])commits?\b/i, // excludes "pre-commit", "post-commit"
|
|
29
|
+
enContext: /\b(infer|guess|preferences?|style|patterns?|history|past|previous|trends?|analy[sz]e|author'?s?)\b/i,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function detectHistory(request) {
|
|
33
|
+
if (RE.history.test(request)) return true;
|
|
34
|
+
// Japanese: コミット + analysis/preference context, but not コミットを修正/fix.
|
|
35
|
+
if (HISTORY_CONTEXT.ja.test(request) && HISTORY_CONTEXT.jaContext.test(request)) return true;
|
|
36
|
+
// English: commit(s) + analysis/preference context.
|
|
37
|
+
if (HISTORY_CONTEXT.en.test(request) && HISTORY_CONTEXT.enContext.test(request)) return true;
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Explicit stage gating: "まずPhase 0だけを開始", "Phase 0の調査が終わるまでは実装を
|
|
42
|
+
// 始めない", "start only phase 0", "don't implement until the investigation is
|
|
43
|
+
// done". The user authorized ONE stage now, so keywords belonging to later phases
|
|
44
|
+
// (実装, fix, browser check, review) must not spawn implement/verify/review tasks.
|
|
45
|
+
const STAGED = {
|
|
46
|
+
// A numbered phase explicitly narrowed with だけ/のみ/only.
|
|
47
|
+
phaseOnly: /(phase|stage|step|フェーズ|段階)\s*\d+\s*(だけ|のみ|only)|\bonly\b[^.\n]{0,15}\b(phase|stage|step)\s*\d+/i,
|
|
48
|
+
// Investigation/research only.
|
|
49
|
+
invOnly: /(調査|分析|investigat\w*|research|analysis)\s*(だけ|のみ)|\b(?:investigat\w*|research|analysis)\b[^.\n]{0,20}\bonly\b|\bonly\b[^.\n]{0,20}\b(?:investigat\w*|research|analysis)\b/i,
|
|
50
|
+
// "まず〜だけ/のみ" or "〜だけ/のみを開始・着手" — one stage authorized now. On its
|
|
51
|
+
// own this is ambiguous ("まずこれだけ直して" is a scoped fix), so it counts only
|
|
52
|
+
// when a phase/investigation marker is present too (see isStagedScope).
|
|
53
|
+
firstOnly: /まず[^。\n]{0,60}?(だけ|のみ)|(だけ|のみ)(?:を|の)(?:開始|着手|実行|進め)/i,
|
|
54
|
+
phaseOrInv: /phase|stage|step|フェーズ|段階|調査|分析|investigat|research/i,
|
|
55
|
+
// An explicit hold on implementation: "〜までは実装を始めない", "実装はまだ",
|
|
56
|
+
// "do not implement until ...", "hold off on implementation".
|
|
57
|
+
hold: /までは?[^。\n]{0,60}?(始めない|しない|やめ|待)|実装(を|は|への)?(始めない|しない|着手しない|入らない|まだ|後で|待って)|\b(?:do not|don'?t|not yet|hold off)\b[^.\n]{0,60}\b(?:implement\w*|cod\w*|build|chang\w*|develop\w*|modif\w*)\b|\bwait\b[^.\n]{0,40}\buntil\b[^.\n]{0,40}\b(?:implement\w*|chang\w*|build)\b|\b(?:implement\w*|development|coding)\b[^.\n]{0,40}\buntil\b/i,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function isStagedScope(request) {
|
|
61
|
+
if (STAGED.hold.test(request) || STAGED.phaseOnly.test(request) || STAGED.invOnly.test(request)) return true;
|
|
62
|
+
return STAGED.firstOnly.test(request) && STAGED.phaseOrInv.test(request);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function classifyRequest(request) {
|
|
66
|
+
const has = k => RE[k].test(request);
|
|
67
|
+
const staged = isStagedScope(request);
|
|
68
|
+
const implement = !staged && (has('implement') || (!has('review') && !has('investigate')));
|
|
69
|
+
return { implement, review: !staged && (has('review') || implement), visual: !staged && has('visual'), browser: !staged && has('browser'), history: detectHistory(request), ui: has('ui'), staged };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A request that mixes several independent investigation concerns (e.g. commit
|
|
74
|
+
* history AND current-implementation analysis AND preference extraction) is too
|
|
75
|
+
* big for one scout pass — it blows the turn budget. Split it into focused
|
|
76
|
+
* sub-investigations plus a synthesis step so each subagent stays small.
|
|
77
|
+
*/
|
|
78
|
+
function splitInvestigation(request, c, add) {
|
|
79
|
+
const concerns = [];
|
|
80
|
+
if (c.history) concerns.push({ key: 'history', title: 'Analyze commit history for preferences', goal: `From the repository's commit history, extract the author's preferences and working style relevant to: ${request}` });
|
|
81
|
+
if (c.ui || c.visual) concerns.push({ key: 'ui', title: 'Inspect current UI/UX implementation', goal: `Investigate the current UI/UX implementation relevant to: ${request}. Identify concrete files and patterns.` });
|
|
82
|
+
// Only split when there are genuinely independent concerns; otherwise one scout.
|
|
83
|
+
if (concerns.length < 2) return null;
|
|
84
|
+
const ids = [];
|
|
85
|
+
for (const con of concerns) {
|
|
86
|
+
const id = add('scout', { kind: 'investigate', title: con.title, goal: con.goal, dependencies: [],
|
|
87
|
+
acceptance: ['concrete evidence (files, commits, or patterns) is reported', 'findings are specific, not generic'] });
|
|
88
|
+
if (id) ids.push(id);
|
|
89
|
+
}
|
|
90
|
+
const synth = add('scout', { kind: 'investigate', title: 'Synthesize implementation context', goal: `Combine the investigation results into a single implementation context for: ${request}`, dependencies: ids,
|
|
91
|
+
acceptance: ['relevant files, constraints and test commands are identified', 'open questions are listed, or explicitly none'] });
|
|
92
|
+
return synth ? [synth] : ids;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function planRules(request, { agents, policy = null }) {
|
|
96
|
+
const names = new Set(workerAgents(agents).map(a => a.meta.name));
|
|
97
|
+
const c = classifyRequest(request);
|
|
98
|
+
const tasks = [];
|
|
99
|
+
const add = (agent, spec) => { if (!names.has(agent)) return null; const id = `t${tasks.length + 1}`; tasks.push({ id, agent, ...spec }); return id; };
|
|
100
|
+
// Split a multi-concern investigation into focused sub-tasks + a synthesis step
|
|
101
|
+
// so no single scout is overloaded (prevents turn-limit stalls on big repos).
|
|
102
|
+
// Runs whenever the request mixes independent concerns, not only when the
|
|
103
|
+
// 'investigate' keyword fired — an implement+history+ui request still needs it.
|
|
104
|
+
const split = splitInvestigation(request, c, add);
|
|
105
|
+
const base = split ?? (() => {
|
|
106
|
+
const scout = add('scout', { kind: 'investigate', title: 'Investigate scope and context', goal: `Identify the files, constraints, open questions and test commands needed for: ${request}`, dependencies: [],
|
|
107
|
+
acceptance: ['relevant files, constraints and test commands are identified', 'open questions are listed, or explicitly none'] });
|
|
108
|
+
return scout ? [scout] : [];
|
|
109
|
+
})();
|
|
110
|
+
const impl = c.implement ? add('coder', { kind: 'implement', title: 'Implement the change', goal: `Implement the smallest sufficient change for: ${request}`, dependencies: base,
|
|
111
|
+
acceptance: ['the requested change is implemented within the investigated constraints', 'the relevant tests were run and pass, or the failure is reported'] }) : null;
|
|
112
|
+
const verify = policy?.verification?.require_tester !== false;
|
|
113
|
+
const tested = impl && verify ? add('tester', { kind: 'verify', title: 'Run tests', goal: `Run the repository tests for: ${request}. Do not implement further changes unless a test command itself is misconfigured.`, dependencies: [impl],
|
|
114
|
+
acceptance: ['the relevant tests were executed', 'pass or fail is reported with the command and output'] }) : null;
|
|
115
|
+
const after = tested ? [tested] : impl ? [impl] : base;
|
|
116
|
+
const checks = [];
|
|
117
|
+
if (c.visual) checks.push(add('visual', { kind: 'verify', title: 'Verify visual result', goal: `Verify the visual outcome of: ${request}`, dependencies: after,
|
|
118
|
+
acceptance: ['the visual result was inspected with concrete evidence'] }));
|
|
119
|
+
if (c.browser) checks.push(add('browser', { kind: 'verify', title: 'Verify in browser', goal: `Verify the browser behavior of: ${request}`, dependencies: after,
|
|
120
|
+
acceptance: ['the page behavior was exercised and observed'] }));
|
|
121
|
+
if (c.review) add('reviewer', { kind: 'review', title: 'Review result and risks', goal: `Review the outcome of "${request}" for correctness, regressions and risk`, dependencies: [...after, ...checks.filter(Boolean)],
|
|
122
|
+
acceptance: ['findings are listed by severity, or explicitly none', 'verified checks are separated from read-only observations'] });
|
|
123
|
+
return { planner: 'rules', classification: c, tasks };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Validate task specs (from any planner or from agent-discovered work) and route them. */
|
|
127
|
+
export function validatePlan(specs, { agents, routing, policy, existingIds = [] }) {
|
|
128
|
+
const errors = [];
|
|
129
|
+
if (!Array.isArray(specs) || !specs.length) return { errors: ['plan: at least one task is required'], tasks: [] };
|
|
130
|
+
const max = policy.limits.max_tasks;
|
|
131
|
+
if (specs.length + existingIds.length > max) errors.push(`plan: ${specs.length + existingIds.length} tasks exceed limits.max_tasks=${max}`);
|
|
132
|
+
const ids = new Set(existingIds);
|
|
133
|
+
const tasks = [];
|
|
134
|
+
for (const s of specs) {
|
|
135
|
+
if (!s || typeof s.id !== 'string' || !s.id) { errors.push('plan: every task needs a string id'); continue; }
|
|
136
|
+
if (ids.has(s.id)) errors.push(`plan: duplicate task id "${s.id}"`);
|
|
137
|
+
ids.add(s.id);
|
|
138
|
+
if (typeof s.title !== 'string' || typeof s.goal !== 'string' || !s.goal.trim()) errors.push(`plan: task "${s.id}" needs title and goal`);
|
|
139
|
+
const r = routeTask(s, { agents, routing });
|
|
140
|
+
if (r.error) { errors.push(`plan: ${r.error}`); continue; }
|
|
141
|
+
tasks.push({ id: s.id, title: s.title, goal: s.goal, kind: s.kind, assignedAgent: r.agent, capability: r.capability,
|
|
142
|
+
dependencies: Array.isArray(s.dependencies) ? s.dependencies : [], acceptance: Array.isArray(s.acceptance) ? s.acceptance.map(String) : [], outputs: Array.isArray(s.outputs) ? s.outputs.map(String) : [] });
|
|
143
|
+
}
|
|
144
|
+
for (const t of tasks) for (const d of t.dependencies) if (!ids.has(d)) errors.push(`plan: task "${t.id}" depends on unknown task "${d}"`);
|
|
145
|
+
const cycle = findCycle(tasks);
|
|
146
|
+
if (cycle) errors.push(`plan: dependency cycle ${cycle.join(' -> ')}`);
|
|
147
|
+
return { errors, tasks };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Fenced blocks paired line by line, so an earlier ```bash block cannot swallow a later ```json fence. */
|
|
151
|
+
export function fencedBlocks(text) {
|
|
152
|
+
const blocks = [];
|
|
153
|
+
let open = null;
|
|
154
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
155
|
+
const fence = /^\s*```\s*([\w-]*)\s*$/.exec(line);
|
|
156
|
+
if (open) { if (fence && !fence[1]) { blocks.push(open); open = null; } else open.body.push(line); }
|
|
157
|
+
else if (fence) open = { lang: fence[1].toLowerCase(), body: [] };
|
|
158
|
+
}
|
|
159
|
+
return blocks.map(b => ({ lang: b.lang, content: b.body.join('\n') }));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function parseJsonBlock(text) {
|
|
163
|
+
const blocks = fencedBlocks(text).reverse();
|
|
164
|
+
for (const b of [...blocks.filter(b => b.lang === 'json'), ...blocks.filter(b => !b.lang)]) { try { return JSON.parse(b.content); } catch { /* try next */ } }
|
|
165
|
+
try { return JSON.parse(String(text).trim()); } catch { return null; }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function plannerPrompt(request, { agents, routing, policy }) {
|
|
169
|
+
const roster = workerAgents(agents).map(a => `- ${a.meta.name} (capability ${a.meta.capability}): ${a.meta.description}`).join('\n');
|
|
170
|
+
const caps = Object.entries(routing.capabilities).map(([k, v]) => `- ${k}: ${v.description ?? ''}`).join('\n');
|
|
171
|
+
return [
|
|
172
|
+
`Request: ${request}`, '', '## Agents', roster, '', '## Capabilities', caps, '',
|
|
173
|
+
`Decompose the request into at most ${policy.limits.max_tasks} tasks. Reply with one fenced json block:`,
|
|
174
|
+
'{"tasks":[{"id":"t1","title":"...","goal":"...","agent":"<agent>","kind":"investigate|implement|verify|review","dependencies":[],"acceptance":["..."]}]}',
|
|
175
|
+
'Use only the agents listed. Every task needs verifiable acceptance criteria. Do not ask the user anything.',
|
|
176
|
+
].join('\n');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function planWithModel(request, { agents, routing, registry, policy, invoke, cwd, trace = [], health = null }) {
|
|
180
|
+
const orch = agents.find(a => a.meta.name === ORCHESTRATOR_AGENT);
|
|
181
|
+
const fallback = reason => ({ ...planRules(request, { agents, policy }), fallbackFrom: 'model', fallbackReason: reason });
|
|
182
|
+
if (!orch) return fallback('orchestrator agent not found');
|
|
183
|
+
let r;
|
|
184
|
+
try {
|
|
185
|
+
r = await withEscalation({ routing, registry, capability: orch.meta.capability, agent: ORCHESTRATOR_AGENT, pack: { previous_attempts: [] }, maxAttempts: policy.limits.model_attempts_per_task, trace, skip: health?.skip, onFailure: health?.report,
|
|
186
|
+
fn: async c => { const x = await invoke({ ...c, systemPrompt: orch.body, prompt: plannerPrompt(request, { agents, routing, policy }), cwd }); return x.ok ? { ok: true, reason: 'plan received', text: x.text } : { ok: false, reason: x.error ?? 'invoke failed' }; } });
|
|
187
|
+
} catch (e) { return fallback(e.message); }
|
|
188
|
+
if (!r.ok) return fallback(r.last?.reason ?? 'planner model failed');
|
|
189
|
+
const json = parseJsonBlock(r.result.text);
|
|
190
|
+
if (!json?.tasks) return fallback('planner output had no JSON tasks');
|
|
191
|
+
const { errors } = validatePlan(json.tasks, { agents, routing, policy });
|
|
192
|
+
if (errors.length) return fallback(errors.join('; '));
|
|
193
|
+
return { planner: 'model', modelId: r.candidate.modelId, tasks: json.tasks };
|
|
194
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Decision policy + orchestration limits. Defaults here are overlaid by orchestration/decision-policy.json
|
|
2
|
+
// and an optional decision-policy.local.json, so behavior is configuration, not code.
|
|
3
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
4
|
+
|
|
5
|
+
export const ESCALATION_FLAGS = [
|
|
6
|
+
'external_publish', 'destructive_action', 'production_risk', 'high_cost',
|
|
7
|
+
'project_cancellation', 'major_direction_change', 'user_value_judgement',
|
|
8
|
+
];
|
|
9
|
+
const AUTO_MODES = new Set(['auto', 'escalate']);
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_POLICY = {
|
|
12
|
+
version: 1,
|
|
13
|
+
decision_policy: {
|
|
14
|
+
prefer_maintainability: true,
|
|
15
|
+
prefer_existing_assets: true,
|
|
16
|
+
prefer_reversible_actions: true,
|
|
17
|
+
max_parallel_tasks: 3,
|
|
18
|
+
poc: { prefer_if_estimated_hours_lte: 2 },
|
|
19
|
+
escalation: Object.fromEntries(ESCALATION_FLAGS.map(f => [f, true])),
|
|
20
|
+
escalation_keywords: {},
|
|
21
|
+
high_cost_threshold_usd: 50,
|
|
22
|
+
major_direction_change_weeks: 2,
|
|
23
|
+
default_behavior: { reversible_decision: 'auto', low_risk_decision: 'auto' },
|
|
24
|
+
reassign_on_failure: true,
|
|
25
|
+
},
|
|
26
|
+
limits: {
|
|
27
|
+
max_tasks: 12, max_retries: 2, max_rounds: 12, max_rework_cycles: 1,
|
|
28
|
+
// Attempt budget. model_attempts_per_task caps candidates tried per task
|
|
29
|
+
// attempt (candidate progression within an attempt). max_total_attempts_per_task
|
|
30
|
+
// caps total model calls across retries so a protocol-failing model cannot
|
|
31
|
+
// consume the whole budget before a different candidate is reached.
|
|
32
|
+
model_attempts_per_task: 3, max_total_attempts_per_task: 4,
|
|
33
|
+
},
|
|
34
|
+
backend_health: { usage_exhausted_ttl_hours: 6, rate_limited_ttl_minutes: 15, unavailable_ttl_minutes: 10 },
|
|
35
|
+
verification: { require_tester: true, require_reviewer: true },
|
|
36
|
+
agent_runtime: {
|
|
37
|
+
max_runtime_ms: 600000, max_tool_calls: 40, max_turns: 12,
|
|
38
|
+
// Per-role / per-complexity turn budgets. A task's initial turn budget is
|
|
39
|
+
// turn_budgets[<role>][<complexity>] falling back to turn_budgets[<role>].normal
|
|
40
|
+
// then max_turns. Bounded progress extension: when a subagent reaches its
|
|
41
|
+
// initial budget but is still making meaningful progress, it may be granted up
|
|
42
|
+
// to extension_turns more turns, at most max_extensions times, never exceeding
|
|
43
|
+
// absolute_max_turns. No unbounded growth.
|
|
44
|
+
turn_budgets: {
|
|
45
|
+
default: { simple: 12, normal: 16, heavy: 20, 'repo-history-heavy': 24 },
|
|
46
|
+
scout: { simple: 12, normal: 16, heavy: 20, 'repo-history-heavy': 24 },
|
|
47
|
+
coder: { simple: 16, normal: 20, heavy: 24, 'repo-history-heavy': 24 },
|
|
48
|
+
tester: { simple: 12, normal: 16, heavy: 20, 'repo-history-heavy': 20 },
|
|
49
|
+
reviewer: { simple: 12, normal: 16, heavy: 20, 'repo-history-heavy': 20 },
|
|
50
|
+
},
|
|
51
|
+
extension_turns: 8, max_extensions: 2, absolute_max_turns: 32,
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const isObj = v => v && typeof v === 'object' && !Array.isArray(v);
|
|
56
|
+
|
|
57
|
+
export function mergePolicy(base, override) {
|
|
58
|
+
if (!isObj(override)) return structuredClone(base);
|
|
59
|
+
const out = structuredClone(base);
|
|
60
|
+
for (const [k, v] of Object.entries(override)) out[k] = isObj(v) && isObj(out[k]) ? mergePolicy(out[k], v) : structuredClone(v);
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function validatePolicy(p) {
|
|
65
|
+
const errors = [];
|
|
66
|
+
const err = m => errors.push(`policy: ${m}`);
|
|
67
|
+
if (!isObj(p)) return ['policy: root must be an object'];
|
|
68
|
+
if (p.version !== 1) err('version must be 1');
|
|
69
|
+
const d = p.decision_policy, l = p.limits;
|
|
70
|
+
if (!isObj(d)) err('decision_policy must be an object');
|
|
71
|
+
else {
|
|
72
|
+
for (const k of ['prefer_maintainability', 'prefer_existing_assets', 'prefer_reversible_actions', 'reassign_on_failure']) if (typeof d[k] !== 'boolean') err(`decision_policy.${k} must be boolean`);
|
|
73
|
+
if (!Number.isInteger(d.max_parallel_tasks) || d.max_parallel_tasks < 1) err('decision_policy.max_parallel_tasks must be an integer >= 1');
|
|
74
|
+
if (typeof d.poc?.prefer_if_estimated_hours_lte !== 'number') err('decision_policy.poc.prefer_if_estimated_hours_lte must be a number');
|
|
75
|
+
for (const [k, v] of Object.entries(d.escalation ?? {})) {
|
|
76
|
+
if (!ESCALATION_FLAGS.includes(k)) err(`decision_policy.escalation has unknown flag "${k}"`);
|
|
77
|
+
if (typeof v !== 'boolean') err(`decision_policy.escalation.${k} must be boolean`);
|
|
78
|
+
}
|
|
79
|
+
for (const [k, v] of Object.entries(d.escalation_keywords ?? {})) {
|
|
80
|
+
if (!ESCALATION_FLAGS.includes(k)) err(`decision_policy.escalation_keywords has unknown flag "${k}"`);
|
|
81
|
+
if (!Array.isArray(v) || v.some(s => typeof s !== 'string')) err(`decision_policy.escalation_keywords.${k} must be a string array`);
|
|
82
|
+
}
|
|
83
|
+
for (const k of ['reversible_decision', 'low_risk_decision']) if (!AUTO_MODES.has(d.default_behavior?.[k])) err(`decision_policy.default_behavior.${k} must be auto|escalate`);
|
|
84
|
+
}
|
|
85
|
+
if (!isObj(l)) err('limits must be an object');
|
|
86
|
+
else for (const k of ['max_tasks', 'max_retries', 'max_rounds', 'max_rework_cycles', 'model_attempts_per_task', 'max_total_attempts_per_task']) {
|
|
87
|
+
const min = k === 'max_retries' || k === 'max_rework_cycles' ? 0 : 1;
|
|
88
|
+
if (l[k] !== undefined && (!Number.isInteger(l[k]) || l[k] < min)) err(`limits.${k} must be an integer >= ${min}`);
|
|
89
|
+
}
|
|
90
|
+
const h = p.backend_health;
|
|
91
|
+
if (h !== undefined) {
|
|
92
|
+
if (!isObj(h)) err('backend_health must be an object');
|
|
93
|
+
else {
|
|
94
|
+
if (typeof h.usage_exhausted_ttl_hours !== 'number' || h.usage_exhausted_ttl_hours <= 0) err('backend_health.usage_exhausted_ttl_hours must be a number > 0');
|
|
95
|
+
for (const k of ['rate_limited_ttl_minutes', 'unavailable_ttl_minutes']) if (typeof h[k] !== 'number' || h[k] <= 0) err(`backend_health.${k} must be a number > 0`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (p.verification !== undefined) {
|
|
99
|
+
if (!isObj(p.verification)) err('verification must be an object');
|
|
100
|
+
else for (const k of ['require_tester', 'require_reviewer']) if (p.verification[k] !== undefined && typeof p.verification[k] !== 'boolean') err(`verification.${k} must be boolean`);
|
|
101
|
+
}
|
|
102
|
+
if (p.agent_runtime !== undefined) {
|
|
103
|
+
const ar = p.agent_runtime;
|
|
104
|
+
if (!isObj(ar)) err('agent_runtime must be an object');
|
|
105
|
+
else {
|
|
106
|
+
for (const k of ['max_runtime_ms', 'max_tool_calls', 'max_turns']) if (!Number.isInteger(ar[k]) || ar[k] < 1) err(`agent_runtime.${k} must be an integer >= 1`);
|
|
107
|
+
for (const k of ['extension_turns', 'max_extensions', 'absolute_max_turns']) if (ar[k] !== undefined && (!Number.isInteger(ar[k]) || ar[k] < 0)) err(`agent_runtime.${k} must be an integer >= 0`);
|
|
108
|
+
if (ar.absolute_max_turns !== undefined && ar.absolute_max_turns < ar.max_turns) err('agent_runtime.absolute_max_turns must be >= max_turns');
|
|
109
|
+
if (ar.turn_budgets !== undefined) {
|
|
110
|
+
if (!isObj(ar.turn_budgets)) err('agent_runtime.turn_budgets must be an object');
|
|
111
|
+
else for (const [role, tiers] of Object.entries(ar.turn_budgets)) {
|
|
112
|
+
if (!isObj(tiers)) err(`agent_runtime.turn_budgets.${role} must be an object`);
|
|
113
|
+
else for (const [tier, v] of Object.entries(tiers)) if (!Number.isInteger(v) || v < 1) err(`agent_runtime.turn_budgets.${role}.${tier} must be an integer >= 1`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return errors;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** DEFAULT_POLICY <- policyPath <- localPath (each optional). Throws on invalid result. */
|
|
122
|
+
export function loadPolicy(policyPath, localPath = null) {
|
|
123
|
+
let p = DEFAULT_POLICY;
|
|
124
|
+
const sources = [];
|
|
125
|
+
for (const path of [policyPath, localPath]) {
|
|
126
|
+
if (!path || !existsSync(path)) continue;
|
|
127
|
+
const { $comment, ...rest } = JSON.parse(readFileSync(path, 'utf8'));
|
|
128
|
+
p = mergePolicy(p, rest);
|
|
129
|
+
sources.push(path);
|
|
130
|
+
}
|
|
131
|
+
const errors = validatePolicy(p);
|
|
132
|
+
if (errors.length) throw new Error(errors.join('\n'));
|
|
133
|
+
return { policy: p, sources };
|
|
134
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Router adapter: task -> capability -> agent -> model, entirely through the existing
|
|
2
|
+
// agents/*.md capability binding, routing.json and the adapter model registry (lib/resolve.mjs).
|
|
3
|
+
import { resolveCapability } from '../resolve.mjs';
|
|
4
|
+
|
|
5
|
+
export const ORCHESTRATOR_AGENT = 'orchestrator';
|
|
6
|
+
|
|
7
|
+
/** Agents that may receive delegated work (the orchestrator never assigns work to itself). */
|
|
8
|
+
export function workerAgents(agents) {
|
|
9
|
+
return agents.filter(a => a.meta.name !== ORCHESTRATOR_AGENT);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function agentForCapability(agents, capability) {
|
|
13
|
+
return workerAgents(agents).find(a => a.meta.capability === capability) ?? null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Fill in agent/capability for a task spec and check both against the kit.
|
|
18
|
+
* An explicit agent keeps its role; an explicit capability overrides only the model route.
|
|
19
|
+
*/
|
|
20
|
+
export function routeTask(spec, { agents, routing }) {
|
|
21
|
+
const byName = Object.fromEntries(workerAgents(agents).map(a => [a.meta.name, a]));
|
|
22
|
+
let agent = spec.assignedAgent ?? spec.agent;
|
|
23
|
+
let capability = spec.capability;
|
|
24
|
+
if (agent === ORCHESTRATOR_AGENT) return { error: `task "${spec.id}": work cannot be assigned to the orchestrator itself` };
|
|
25
|
+
if (agent && !byName[agent]) return { error: `task "${spec.id}": unknown agent "${agent}"` };
|
|
26
|
+
if (!agent && capability) agent = agentForCapability(agents, capability)?.meta.name;
|
|
27
|
+
if (!agent) return { error: `task "${spec.id}": no agent for capability "${capability}"` };
|
|
28
|
+
capability = capability ?? byName[agent].meta.capability;
|
|
29
|
+
if (!routing.capabilities[capability]) return { error: `task "${spec.id}": capability "${capability}" is not defined in routing` };
|
|
30
|
+
return { agent, capability };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Concrete model chain for a task's capability (report shape from resolveCapability). */
|
|
34
|
+
export function resolveTaskModels(task, { routing, registry }) {
|
|
35
|
+
return resolveCapability(routing, registry, task.capability);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Next capability after `capability` in any routing escalation ladder (e.g. cheap-code -> strong-code). */
|
|
39
|
+
export function nextLadderCapability(routing, capability) {
|
|
40
|
+
for (const steps of Object.values(routing.escalation?.ladders ?? {})) {
|
|
41
|
+
const i = steps.indexOf(capability);
|
|
42
|
+
if (i >= 0 && i + 1 < steps.length) return steps[i + 1];
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|