@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,278 @@
|
|
|
1
|
+
// AgentRunner: oneshot (pi -p --no-tools), pipeline (existing scout->coder path), or an injected
|
|
2
|
+
// tool-capable subagent. pi process details stay in the adapter behind `runSubagent`.
|
|
3
|
+
import { join, relative, resolve, isAbsolute } from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { withEscalation, runPipeline, surveyRepo } from '../pipeline.mjs';
|
|
6
|
+
import { parseJsonBlock } from './planner.mjs';
|
|
7
|
+
import { buildTaskContract, RESULT_STATUSES } from './contract.mjs';
|
|
8
|
+
import { accessOf, piToolsForAccess, resolveExecutionMode, workspaceOf } from './permissions.mjs';
|
|
9
|
+
import { captureWorktree, diffWorktree } from './worktree.mjs';
|
|
10
|
+
import { classifyRun, REASSIGN_CLASSES, isProtocolFailure, shouldMarkTaskGlobalFailure } from './failures.mjs';
|
|
11
|
+
import { dangerousCommands } from './shell-policy.mjs';
|
|
12
|
+
import { classifyTaskComplexity, initialTurnBudget } from './turn-budget.mjs';
|
|
13
|
+
|
|
14
|
+
const arr = v => (Array.isArray(v) ? v : []);
|
|
15
|
+
|
|
16
|
+
/** Structured result from agent text. Unstructured replies are kept as summary and flagged. */
|
|
17
|
+
export function parseStructuredResult(text) {
|
|
18
|
+
const json = parseJsonBlock(text);
|
|
19
|
+
if (!json || typeof json !== 'object' || !RESULT_STATUSES.includes(json.status)) {
|
|
20
|
+
return { structured: false, failureClass: 'MALFORMED_RESULT', result: { status: 'unknown', summary: String(text ?? '').trim().slice(0, 2000), artifacts: [], filesChanged: [], commandsRun: [], verification: [], acceptance: [], remainingIssues: [], decisions: [], newTasks: [] } };
|
|
21
|
+
}
|
|
22
|
+
const status = json.status === 'needs_decision' ? 'blocked' : json.status;
|
|
23
|
+
const verification = arr(json.verification).map(v => (typeof v === 'string' ? { result: v } : v));
|
|
24
|
+
return { structured: true, result: {
|
|
25
|
+
status, summary: String(json.summary ?? ''),
|
|
26
|
+
artifacts: arr(json.artifacts ?? json.filesChanged).map(String),
|
|
27
|
+
filesChanged: arr(json.filesChanged).map(String),
|
|
28
|
+
commandsRun: arr(json.commandsRun).map(String),
|
|
29
|
+
verification, acceptance: arr(json.acceptance),
|
|
30
|
+
remainingIssues: arr(json.remainingIssues).map(i => (typeof i === 'string' ? { summary: i, blocking: false } : i)),
|
|
31
|
+
decisions: arr(json.decisions), newTasks: arr(json.newTasks ?? json.discoveredTasks),
|
|
32
|
+
} };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const DIRTY_GATE_TYPE = 'worktree-dirty';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Dirty-worktree safety gate. Only PRE-EXISTING, UNRELATED changes gate an
|
|
39
|
+
* implementation: paths this run's agents already changed (`ownedPaths`) are
|
|
40
|
+
* excluded, and a non-git workspace (file inventory) is never treated as dirty.
|
|
41
|
+
* The decision key is derived from the set of unrelated paths so an answered
|
|
42
|
+
* decision for the same condition is recognised across retries / rework tasks.
|
|
43
|
+
* @returns {{ gate: false } | { gate: true, action: 'ask'|'proceed'|'abort', paths: string[], key: string, decision?: object }}
|
|
44
|
+
*/
|
|
45
|
+
export function evaluateDirtyGate({ before, ownedPaths = [], decisions = [] }) {
|
|
46
|
+
if (before?.source !== 'git') return { gate: false, reason: 'not a git workspace' };
|
|
47
|
+
const owned = new Set(ownedPaths);
|
|
48
|
+
const paths = Object.keys(before.entries ?? {}).filter(p => !owned.has(p)).sort();
|
|
49
|
+
if (!paths.length) return { gate: false, reason: owned.size ? 'only agent-owned changes' : 'clean' };
|
|
50
|
+
const digest = createHash('sha1').update(paths.join('\n')).digest('hex').slice(0, 12);
|
|
51
|
+
const key = `${DIRTY_GATE_TYPE}:${digest}`;
|
|
52
|
+
const answered = decisions.filter(d => d && (d.key === key || d.decisionType === DIRTY_GATE_TYPE && d.key === key));
|
|
53
|
+
const optionOf = d => d.optionId ?? (/^proceed/i.test(String(d.choice ?? '')) ? 'proceed' : /^abort/i.test(String(d.choice ?? '')) ? 'abort' : null);
|
|
54
|
+
const last = answered.map(optionOf).filter(Boolean).at(-1);
|
|
55
|
+
if (last === 'proceed') return { gate: true, action: 'proceed', paths, key };
|
|
56
|
+
if (last === 'abort') return { gate: true, action: 'abort', paths, key };
|
|
57
|
+
return {
|
|
58
|
+
gate: true, action: 'ask', paths, key,
|
|
59
|
+
decision: {
|
|
60
|
+
key, decisionType: DIRTY_GATE_TYPE,
|
|
61
|
+
question: `Workspace has ${paths.length} pre-existing uncommitted change(s) not made by this run (${paths.slice(0, 5).join(', ')}${paths.length > 5 ? ', …' : ''}). Proceed with implementation anyway?`,
|
|
62
|
+
flags: ['destructive_action'],
|
|
63
|
+
options: [
|
|
64
|
+
{ id: 'proceed', summary: 'proceed; existing changes are unrelated or acceptable', reversible: false, flags: ['destructive_action'] },
|
|
65
|
+
{ id: 'abort', summary: 'do not implement; keep the dirty tree untouched', reversible: true },
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function guardWorkspace(workspace, repoRoot) {
|
|
72
|
+
if (!workspace?.path) return null;
|
|
73
|
+
const root = resolve(repoRoot ?? workspace.path);
|
|
74
|
+
const target = resolve(workspace.path);
|
|
75
|
+
const rel = relative(root, target);
|
|
76
|
+
if (rel.startsWith('..') || isAbsolute(rel)) return `workspace ${target} is outside ${root}`;
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function childRecord(task, ctx, extra) {
|
|
81
|
+
return {
|
|
82
|
+
taskId: task.id, runId: ctx.runId ?? null, agent: task.assignedAgent, capability: task.capability,
|
|
83
|
+
startedAt: extra.startedAt, finishedAt: extra.finishedAt, durationMs: extra.durationMs ?? null, status: extra.status,
|
|
84
|
+
childSessionId: extra.childSessionId ?? null, modelId: extra.modelId ?? null, backend: extra.backend ?? null, provider: extra.provider ?? null, failureClass: extra.failureClass ?? null,
|
|
85
|
+
toolCalls: extra.toolCalls ?? null, turns: extra.turns ?? null,
|
|
86
|
+
uniqueFilesInspected: extra.uniqueFilesInspected ?? 0, toolNames: extra.toolNames ?? {},
|
|
87
|
+
commandsExecuted: extra.commandsExecuted ?? 0, extensionsGranted: extra.extensionsGranted ?? 0,
|
|
88
|
+
initialTurns: extra.initialTurns, finalTurnLimit: extra.finalTurnLimit, stopReason: extra.stopReason ?? null,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {object} o
|
|
94
|
+
* @param {Function} o.invoke adapter invoker ({ modelId, systemPrompt, prompt, cwd }) -> { ok, text, error }
|
|
95
|
+
* @param {Function} [o.runSubagent] tool-capable child ({ modelId, systemPrompt, prompt, cwd, toolNames, shell, limits })
|
|
96
|
+
* @param {string[]} [o.pipelineAgents] agents executed through runPipeline (writes files; opt-in)
|
|
97
|
+
*/
|
|
98
|
+
export function createAgentRunner({ invoke, runSubagent = null, agents, routing, registry, repoRoot = null, outDir = null, maxModelAttempts = 2, pipelineAgents = [], surveyKinds = ['investigate', 'review', 'verify'], health = null, policy = null, session = null }) {
|
|
99
|
+
const byName = Object.fromEntries(agents.map(a => [a.meta.name, a]));
|
|
100
|
+
const runtime = policy?.agent_runtime ?? { max_runtime_ms: 600000, max_tool_calls: 40, max_turns: 12 };
|
|
101
|
+
// Resolved per-task execution limits: role+complexity initial turn budget plus
|
|
102
|
+
// the bounded-extension knobs. The subagent runner enforces progress-aware
|
|
103
|
+
// extension inside a single invocation (never consumes the attempt budget).
|
|
104
|
+
const limitsFor = (task, agent) => {
|
|
105
|
+
const complexity = classifyTaskComplexity(task);
|
|
106
|
+
const turns = initialTurnBudget(runtime, agent?.meta?.name ?? 'default', complexity);
|
|
107
|
+
return { ...runtime, max_turns: turns, complexity, extension_turns: runtime.extension_turns ?? 0, max_extensions: runtime.max_extensions ?? 0, absolute_max_turns: runtime.absolute_max_turns ?? turns };
|
|
108
|
+
};
|
|
109
|
+
// Remaining real-invocation budget for this task across retries.
|
|
110
|
+
const invocationsBudget = task => {
|
|
111
|
+
const cap = policy?.limits?.max_total_attempts_per_task;
|
|
112
|
+
return cap == null ? null : Math.max(0, cap - (task?.totalModelAttempts ?? 0));
|
|
113
|
+
};
|
|
114
|
+
// Candidates to skip entirely: capability-local tried + task-global failed models.
|
|
115
|
+
const excludeModels = task => task?.attemptedModels ?? [];
|
|
116
|
+
const telemetryFor = out => ({
|
|
117
|
+
toolCalls: out?.child?.toolCalls ?? out?.telemetry?.toolCalls ?? out?.toolCalls,
|
|
118
|
+
turnLimit: ['no-progress-turn-limit', 'absolute-turn-limit'].includes(out?.child?.stopReason) || /turn limit/i.test(out?.error ?? ''),
|
|
119
|
+
structuredProgress: out?.structuredProgress ?? ((out?.text ?? out?.telemetry?.text) != null ? parseStructuredResult(out.text ?? out.telemetry.text).structured : undefined),
|
|
120
|
+
hasFinalOutput: (out?.text ?? out?.telemetry?.text) != null ? parseStructuredResult(out.text ?? out.telemetry.text).structured : undefined,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Protocol-reliability telemetry (audit only; not fed back into routing).
|
|
124
|
+
const recordProtocol = (candidate, kind) => {
|
|
125
|
+
try { session?.recordProtocol?.({ provider: candidate?.provider, model: candidate?.model ?? candidate?.modelId, kind }); } catch { /* never block a run on telemetry */ }
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
async function viaPipeline(task, ctx) {
|
|
129
|
+
const context = ctx.dependencyResults.map(d => `- ${d.id}: ${String(d.summary ?? '').slice(0, 600)}`).join('\n');
|
|
130
|
+
const summary = await runPipeline({ repoRoot, task: `${task.goal}${context ? `\n\nContext from earlier steps:\n${context}` : ''}`, routing, registry, agents, invoke, outDir: join(outDir ?? join(repoRoot, '.orchestrate-out'), task.id), maxAttempts: maxModelAttempts, health });
|
|
131
|
+
const ok = summary.outcome === 'success';
|
|
132
|
+
const evidence = ok ? `pipeline outcome success; \`${summary.testCommand ?? 'tests'}\` passed after applying ${summary.applied?.join(', ')}` : `pipeline outcome ${summary.outcome}`;
|
|
133
|
+
const n = Math.max(task.acceptance.length, 1);
|
|
134
|
+
return { ok: true, structured: true, executor: 'pipeline', steps: summary.steps, modelId: summary.steps.at(-1)?.modelId, result: {
|
|
135
|
+
status: ok ? 'completed' : 'failed', summary: evidence, artifacts: summary.applied ?? [], filesChanged: summary.applied ?? [], commandsRun: [], verification: ok ? [{ command: summary.testCommand, result: 'pass' }] : [{ command: summary.testCommand, result: evidence }],
|
|
136
|
+
acceptance: Array.from({ length: n }, (_, i) => ({ id: `A${i + 1}`, met: ok, evidence })), remainingIssues: ok ? [] : [{ summary: evidence, blocking: true }], decisions: [], newTasks: [],
|
|
137
|
+
}, failureClass: ok ? undefined : 'TEST_FAILURE' };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function viaSubagent(task, agent, ctx) {
|
|
141
|
+
const workspace = workspaceOf(task, repoRoot);
|
|
142
|
+
const outside = guardWorkspace(workspace, repoRoot);
|
|
143
|
+
if (outside) return { ok: false, error: outside, failureClass: 'POLICY_BLOCK', steps: [], executor: 'subagent' };
|
|
144
|
+
const access = accessOf(agent);
|
|
145
|
+
const before = workspace.path ? captureWorktree(workspace.path) : { available: false, entries: {} };
|
|
146
|
+
// Working-tree safety: a write-capable implementation on a dirty tree may
|
|
147
|
+
// collide with PRE-EXISTING user changes. Read-only tasks proceed; changes
|
|
148
|
+
// made by this run's agents (retry / rework) are not "dirty"; a non-git
|
|
149
|
+
// workspace is never gated; an already-answered decision is honoured.
|
|
150
|
+
if (access.filesystem === 'read-write') {
|
|
151
|
+
const g = evaluateDirtyGate({ before, ownedPaths: ctx.ownedPaths ?? [], decisions: [...(ctx.runDecisions ?? []), ...(task.decisions ?? [])] });
|
|
152
|
+
if (g.gate && g.action === 'ask') {
|
|
153
|
+
return { ok: false, error: `workspace has ${g.paths.length} pre-existing uncommitted change(s); implementation may collide with existing work`, failureClass: 'USER_DECISION_REQUIRED', steps: [], executor: 'subagent', decision: g.decision, gate: { type: DIRTY_GATE_TYPE, key: g.key, action: 'ask', paths: g.paths } };
|
|
154
|
+
}
|
|
155
|
+
if (g.gate && g.action === 'abort') {
|
|
156
|
+
return { ok: false, error: 'implementation aborted by user decision: workspace has pre-existing uncommitted changes', failureClass: 'POLICY_BLOCK', abort: true, steps: [], executor: 'subagent', gate: { type: DIRTY_GATE_TYPE, key: g.key, action: 'abort', paths: g.paths } };
|
|
157
|
+
}
|
|
158
|
+
if (g.gate) ctx = { ...ctx, gate: { type: DIRTY_GATE_TYPE, key: g.key, action: 'proceed', paths: g.paths } };
|
|
159
|
+
}
|
|
160
|
+
const survey = workspace.path && surveyKinds.includes(task.kind) ? surveyRepo(workspace.path, { maxTotalInline: 8000 }) : null;
|
|
161
|
+
const prompt = buildTaskContract(task, { ...ctx, survey, access, workspace });
|
|
162
|
+
const steps = [];
|
|
163
|
+
const startedAt = new Date().toISOString();
|
|
164
|
+
let invocationSeq = 0;
|
|
165
|
+
let previousModel = null;
|
|
166
|
+
const emit = (type, data) => ctx.onEvent?.(type, { taskId: task.id, ...data });
|
|
167
|
+
let r;
|
|
168
|
+
try {
|
|
169
|
+
r = await withEscalation({ routing, registry, capability: task.capability, agent: agent.meta.name, pack: { previous_attempts: [] }, maxAttempts: maxModelAttempts, trace: steps, skip: health?.skip, onFailure: health?.report, excludeModels: excludeModels(task), taskGlobalFailedModels: task.taskGlobalFailedModels, invocationsBudget: invocationsBudget(task),
|
|
170
|
+
fn: async (c, attempt) => {
|
|
171
|
+
const invocationId = `${task.id}-i${++invocationSeq}`;
|
|
172
|
+
if (attempt > 0) {
|
|
173
|
+
const prior = [...steps].reverse().find(s => !s.skipped);
|
|
174
|
+
const reason = prior?.protocolFailure ?? prior?.failureClass ?? 'candidate failed';
|
|
175
|
+
emit('candidate-changed', { invocationId, agent: agent.meta.name, capability: task.capability, fromModel: previousModel, toModel: c.modelId, reason });
|
|
176
|
+
}
|
|
177
|
+
previousModel = c.modelId;
|
|
178
|
+
emit('invocation-start', { invocationId, agent: agent.meta.name, capability: task.capability, modelId: c.modelId, backend: c.backend, provider: c.provider });
|
|
179
|
+
const forward = (type, data) => emit(type, { invocationId, ...data });
|
|
180
|
+
let out;
|
|
181
|
+
try {
|
|
182
|
+
out = await runSubagent({ ...c, capability: task.capability, systemPrompt: agent.body, prompt, cwd: workspace.path ?? repoRoot ?? process.cwd(), toolNames: piToolsForAccess(access), access, limits: limitsFor(task, agent), taskId: task.id, runId: ctx.runId, onEvent: forward });
|
|
183
|
+
} catch (error) {
|
|
184
|
+
emit('invocation-end', { invocationId, agent: agent.meta.name, modelId: c.modelId, status: 'failed', reason: 'MODEL_FAILURE' });
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
const klass = out.failureClass || classifyRun(out);
|
|
188
|
+
const probe = out.ok ? parseStructuredResult(out.text) : null;
|
|
189
|
+
const endFailure = !out.ok ? klass : !probe.structured ? 'MALFORMED_RESULT' : null;
|
|
190
|
+
emit('invocation-end', { invocationId, agent: agent.meta.name, modelId: c.modelId, status: endFailure ? 'failed' : 'finished', reason: endFailure ?? undefined,
|
|
191
|
+
...(out.child?.turns != null ? { turnsUsed: out.child.turns } : {}), ...(out.child?.toolCalls != null ? { toolCalls: out.child.toolCalls } : {}) });
|
|
192
|
+
if (!out.ok && REASSIGN_CLASSES.has(klass)) return { ok: false, reason: out.error ?? klass, failureClass: klass, protocolFailure: klass === 'MODEL_FAILURE' ? klass : undefined, child: out.child, telemetry: out.telemetry };
|
|
193
|
+
if (!out.ok) {
|
|
194
|
+
// Protocol-quality failure -> return ok:false so withEscalation advances
|
|
195
|
+
// to the NEXT candidate within this same attempt instead of retrying
|
|
196
|
+
// this model. A turn-limit timeout WITH tool progress stays retryable.
|
|
197
|
+
if (isProtocolFailure(klass, out.child ?? out)) {
|
|
198
|
+
recordProtocol(c, klass === 'EMPTY_RESPONSE' ? 'empty' : (klass === 'TIMEOUT' || klass === 'NO_PROGRESS_TIMEOUT') ? 'turn_limit' : 'malformed');
|
|
199
|
+
return { ok: false, reason: out.error ?? klass, protocolFailure: shouldMarkTaskGlobalFailure(klass, telemetryFor(out)) ? klass : undefined, failureClass: klass, child: out.child, telemetry: out.telemetry ?? out.child };
|
|
200
|
+
}
|
|
201
|
+
return { ok: true, reason: 'child stopped', terminalFailure: klass, error: out.error, child: out.child, text: out.text ?? '', telemetry: out.child };
|
|
202
|
+
}
|
|
203
|
+
// Got text back — but if it isn't a structured result, that is a
|
|
204
|
+
// MALFORMED_RESULT protocol failure: advance to the next candidate too.
|
|
205
|
+
if (!probe.structured) { recordProtocol(c, 'malformed'); return { ok: false, reason: 'no structured result block', protocolFailure: 'MALFORMED_RESULT', child: out.child, text: out.text, telemetry: out.child }; }
|
|
206
|
+
recordProtocol(c, 'structured_ok');
|
|
207
|
+
return { ok: true, reason: 'response received', text: out.text, child: out.child, telemetry: out.child };
|
|
208
|
+
} });
|
|
209
|
+
} catch (e) {
|
|
210
|
+
return { ok: false, error: e.message, failureClass: classifyRun({ error: e.message }), steps, executor: 'subagent' };
|
|
211
|
+
}
|
|
212
|
+
const after = workspace.path ? captureWorktree(workspace.path) : before;
|
|
213
|
+
const agentChanges = diffWorktree(before, after);
|
|
214
|
+
const finishedAt = new Date().toISOString();
|
|
215
|
+
const payload = r.result ?? {};
|
|
216
|
+
// On protocol failure r.result is empty, so payload.child is missing. Recover
|
|
217
|
+
// the last invoked candidate's child (it holds real toolCalls/turns/modelId)
|
|
218
|
+
// from r.last so telemetry isn't lost. Never invent values — only reuse what
|
|
219
|
+
// the runner actually recorded.
|
|
220
|
+
const lastStep = [...steps].reverse().find(s => !s.skipped);
|
|
221
|
+
const lastChild = r.last?.child ?? payload.child ?? {};
|
|
222
|
+
const lastModelId = r.candidate?.modelId ?? lastStep?.modelId ?? lastChild.modelId ?? r.invokedModels?.at(-1);
|
|
223
|
+
const child = childRecord(task, ctx, { ...lastChild,
|
|
224
|
+
toolCalls: lastChild.toolCalls ?? lastStep?.telemetry?.toolCalls,
|
|
225
|
+
turns: lastChild.turns ?? lastStep?.telemetry?.turns,
|
|
226
|
+
startedAt: lastStep?.startedAt ?? startedAt, finishedAt,
|
|
227
|
+
durationMs: lastStep?.durationMs,
|
|
228
|
+
status: r.ok && !payload.terminalFailure ? 'finished' : 'failed', modelId: lastModelId,
|
|
229
|
+
backend: r.candidate?.backend ?? lastStep?.backend ?? lastChild.backend,
|
|
230
|
+
provider: r.candidate?.provider ?? lastStep?.provider ?? lastChild.provider,
|
|
231
|
+
failureClass: r.ok ? payload.terminalFailure : (lastStep?.failureClass ?? lastStep?.protocolFailure ?? null),
|
|
232
|
+
});
|
|
233
|
+
if (!r.ok) return { ok: false, error: r.last?.reason ?? 'no model candidate succeeded', failureClass: r.last?.protocolFailure ?? classifyRun({ error: r.last?.reason }), steps, executor: 'subagent', child, worktree: { before, after, agentChanges }, invokedModels: r.invokedModels, counters: r.counters, modelId: r.invokedModels?.at(-1) };
|
|
234
|
+
if (payload.terminalFailure) return { ok: false, error: payload.error ?? payload.terminalFailure, failureClass: payload.terminalFailure, steps, executor: 'subagent', child, worktree: { before, after, agentChanges }, invokedModels: r.invokedModels, counters: r.counters, modelId: r.candidate?.modelId };
|
|
235
|
+
const parsed = parseStructuredResult(payload.text);
|
|
236
|
+
if (agentChanges.length) {
|
|
237
|
+
const seen = new Set(parsed.result.filesChanged);
|
|
238
|
+
for (const change of agentChanges) if (!seen.has(change.path)) parsed.result.filesChanged.push(change.path);
|
|
239
|
+
}
|
|
240
|
+
return { ok: true, executor: 'subagent', raw: payload.text, structured: parsed.structured, result: parsed.result, failureClass: parsed.failureClass, modelId: r.candidate.modelId, backend: r.candidate.backend, steps, child, worktree: { before, after, agentChanges }, invokedModels: r.invokedModels, counters: r.counters, gate: ctx.gate };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
async run(task, ctx = { dependencyResults: [] }) {
|
|
245
|
+
const agent = byName[task.assignedAgent];
|
|
246
|
+
if (!agent) return { ok: false, error: `unknown agent "${task.assignedAgent}"`, failureClass: 'UNKNOWN', steps: [] };
|
|
247
|
+
const mode = resolveExecutionMode(agent, task, { pipelineAgents, hasSubagent: typeof runSubagent === 'function' });
|
|
248
|
+
if (mode === 'pipeline') {
|
|
249
|
+
try { return await viaPipeline(task, ctx); } catch (e) { return { ok: false, error: `pipeline: ${e.message}`, failureClass: 'TOOL_FAILURE', steps: [], executor: 'pipeline' }; }
|
|
250
|
+
}
|
|
251
|
+
if (mode === 'subagent') return viaSubagent(task, agent, ctx);
|
|
252
|
+
const survey = repoRoot && surveyKinds.includes(task.kind) ? surveyRepo(repoRoot, { maxTotalInline: 12000 }) : null;
|
|
253
|
+
const prompt = buildTaskContract(task, { ...ctx, survey, access: accessOf(agent), workspace: workspaceOf(task, repoRoot) });
|
|
254
|
+
const steps = [];
|
|
255
|
+
let r;
|
|
256
|
+
try {
|
|
257
|
+
r = await withEscalation({ routing, registry, capability: task.capability, agent: agent.meta.name, pack: { previous_attempts: [] }, maxAttempts: maxModelAttempts, trace: steps, skip: health?.skip, onFailure: health?.report, excludeModels: excludeModels(task), taskGlobalFailedModels: task.taskGlobalFailedModels, invocationsBudget: invocationsBudget(task),
|
|
258
|
+
fn: async c => {
|
|
259
|
+
const x = await invoke({ ...c, systemPrompt: agent.body, prompt, cwd: repoRoot ?? process.cwd() });
|
|
260
|
+
if (!x.ok) {
|
|
261
|
+
const klass = x.failureClass || classifyRun({ error: x.error });
|
|
262
|
+
if (isProtocolFailure(klass, x)) { recordProtocol(c, klass === 'EMPTY_RESPONSE' ? 'empty' : klass === 'TIMEOUT' ? 'turn_limit' : 'malformed'); return { ok: false, reason: x.error ?? klass, protocolFailure: shouldMarkTaskGlobalFailure(klass, telemetryFor(x)) ? klass : undefined, failureClass: klass }; }
|
|
263
|
+
return { ok: false, reason: x.error ?? 'invoke failed', failureClass: klass, protocolFailure: klass === 'MODEL_FAILURE' ? klass : undefined };
|
|
264
|
+
}
|
|
265
|
+
const probe = parseStructuredResult(x.text);
|
|
266
|
+
if (!probe.structured) { recordProtocol(c, 'malformed'); return { ok: false, reason: 'no structured result block', protocolFailure: 'MALFORMED_RESULT', text: x.text }; }
|
|
267
|
+
recordProtocol(c, 'structured_ok');
|
|
268
|
+
return { ok: true, reason: 'response received', text: x.text };
|
|
269
|
+
} });
|
|
270
|
+
} catch (e) { return { ok: false, error: e.message, failureClass: classifyRun({ error: e.message }), steps, executor: 'oneshot' }; }
|
|
271
|
+
if (!r.ok) return { ok: false, error: r.last?.reason ?? 'no model candidate succeeded', failureClass: r.last?.protocolFailure ?? classifyRun({ error: r.last?.reason }), steps, executor: 'oneshot', invokedModels: r.invokedModels, counters: r.counters, modelId: r.invokedModels?.at(-1) };
|
|
272
|
+
const parsed = parseStructuredResult(r.result.text);
|
|
273
|
+
return { ok: true, executor: 'oneshot', raw: r.result.text, structured: parsed.structured, result: parsed.result, failureClass: parsed.failureClass, modelId: r.candidate.modelId, backend: r.candidate.backend, steps, invokedModels: r.invokedModels, counters: r.counters };
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export { dangerousCommands };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Shell gate used by the pi extension and by result evaluation. Safe inspection and tests may run.
|
|
2
|
+
// Publish, push, production mutation, and destructive deletes do not.
|
|
3
|
+
const DENY = [
|
|
4
|
+
{ re: /\bgit(\.exe)?\s+push\b/i, reason: 'git push is an external publish' },
|
|
5
|
+
{ re: /\b(npm|pnpm|yarn)(\.cmd)?\s+publish\b/i, reason: 'package publish is an external publish' },
|
|
6
|
+
{ re: /\bgh\s+(release|repo\s+create|pr\s+merge)\b/i, reason: 'external publish' },
|
|
7
|
+
{ re: /\b(terraform\s+apply|kubectl\s+delete|serverless\s+deploy|wrangler\s+deploy|flyctl\s+deploy)\b/i, reason: 'deployment' },
|
|
8
|
+
{ re: /\b(deploy|production)\b/i, reason: 'production or deploy command' },
|
|
9
|
+
{ re: /\bgit(\.exe)?\s+(reset|clean|stash|checkout\s+--)\b/i, reason: 'would discard or hide working tree changes' },
|
|
10
|
+
{ re: /\b(curl|wget|invoke-webrequest|invoke-restmethod|\biwr\b)\b/i, reason: 'network transfer' },
|
|
11
|
+
{ re: /\b(setx|reg(\.exe)?\s+add|net\s+user)\b/i, reason: 'credential or account change' },
|
|
12
|
+
{ re: /\b(remove-item|rm|del|erase|rmdir|rd)\b[^\n]*(-recurse|-r\b|\/s|\/q|-rf|-fr)/i, reason: 'recursive delete' },
|
|
13
|
+
{ re: /\brm\s+-rf?\b/i, reason: 'recursive delete' },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
const SAFE_DELETE = /\b(node_modules|dist|build|coverage|\.orchestrate-out)(\\|\/|\s|$)/i;
|
|
17
|
+
|
|
18
|
+
const LIMITED = [
|
|
19
|
+
/^(git(\.exe)?)\s+(status|diff|log|show|rev-parse)\b/i,
|
|
20
|
+
/^(npm(\.cmd)?)\s+(test|run\s+test|run\s+lint|run\s+build)\b/i,
|
|
21
|
+
/^(pnpm(\.cmd)?)\s+(test|lint|build)\b/i,
|
|
22
|
+
/^node(\.exe)?\s+--test\b/i,
|
|
23
|
+
/^npx(\.cmd)?\s+(vitest|eslint)\b/i,
|
|
24
|
+
/^(Get-ChildItem|Get-Content|Select-String)\b/i,
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
export function decideShell(command, { shell = 'limited', network = false } = {}) {
|
|
28
|
+
const text = String(command ?? '').trim();
|
|
29
|
+
if (!text) return { allow: false, reason: 'empty command' };
|
|
30
|
+
if (shell === 'false' || shell === false) return { allow: false, reason: 'this agent has no shell access' };
|
|
31
|
+
for (const rule of DENY) {
|
|
32
|
+
if (!rule.re.test(text)) continue;
|
|
33
|
+
if (rule.reason === 'recursive delete' && SAFE_DELETE.test(text)) continue;
|
|
34
|
+
if (rule.reason === 'network transfer' && network) continue;
|
|
35
|
+
return { allow: false, reason: rule.reason, flags: flagsFor(rule.reason) };
|
|
36
|
+
}
|
|
37
|
+
if (String(shell) === 'limited' && !LIMITED.some(re => re.test(text))) {
|
|
38
|
+
return { allow: false, reason: 'command is outside the limited shell allowlist' };
|
|
39
|
+
}
|
|
40
|
+
return { allow: true, reason: 'safe command' };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function flagsFor(reason) {
|
|
44
|
+
if (/publish|push/.test(reason)) return ['external_publish'];
|
|
45
|
+
if (/production|deploy/.test(reason)) return ['production_risk'];
|
|
46
|
+
if (/credential/.test(reason)) return ['destructive_action'];
|
|
47
|
+
return ['destructive_action'];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function dangerousCommands(commands, access) {
|
|
51
|
+
return (commands ?? []).map(String).map(command => ({ command, ...decideShell(command, access) })).filter(d => !d.allow);
|
|
52
|
+
}
|