@agentprojectcontext/apx 1.65.3 → 1.67.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/package.json +3 -2
- package/skills/apx/SKILL.md +3 -0
- package/src/core/agent/index.js +2 -0
- package/src/core/agent/judge.js +174 -0
- package/src/core/agent/model-router.js +107 -5
- package/src/core/agent/prompts/modes/code-build.md +1 -1
- package/src/core/agent/run-agent.js +149 -12
- package/src/core/agent/security.js +97 -0
- package/src/core/agent/stuck-detector.js +89 -0
- package/src/core/agent/super-agent.js +58 -17
- package/src/core/agent/tools/handlers/run-subagent.js +117 -0
- package/src/core/agent/tools/helpers.js +11 -1
- package/src/core/agent/tools/names.js +2 -0
- package/src/core/agent/tools/registry.js +10 -0
- package/src/core/artifacts/preview.js +392 -0
- package/src/core/artifacts/tunnel.js +169 -0
- package/src/core/config/index.js +62 -1
- package/src/core/config/secret-values.js +132 -0
- package/src/core/engines/mock.js +15 -1
- package/src/core/engines/presets.js +102 -0
- package/src/core/logging.js +10 -3
- package/src/core/memory/compactor.js +65 -56
- package/src/core/memory/summarizer.js +125 -0
- package/src/core/stores/conversations-compactor.js +24 -31
- package/src/host/daemon/api/admin-config.js +5 -0
- package/src/host/daemon/api/artifact-preview.js +82 -0
- package/src/host/daemon/api/engines.js +6 -0
- package/src/host/daemon/api/web.js +1 -1
- package/src/host/daemon/api.js +2 -0
- package/src/host/daemon/index.js +16 -1
- package/src/interfaces/acp/index.js +363 -0
- package/src/interfaces/acp/jsonrpc.js +180 -0
- package/src/interfaces/acp/session.js +205 -0
- package/src/interfaces/cli/commands/acp.js +10 -0
- package/src/interfaces/cli/commands/artifact.js +115 -0
- package/src/interfaces/cli/commands/setup.js +6 -3
- package/src/interfaces/cli/index.js +74 -0
- package/src/interfaces/web/dist/assets/index-B3pEwe1m.js +803 -0
- package/src/interfaces/web/dist/assets/index-B3pEwe1m.js.map +1 -0
- package/src/interfaces/web/dist/assets/index-BPGECxzm.css +1 -0
- package/src/interfaces/web/dist/index.html +2 -2
- package/src/interfaces/web/package-lock.json +6 -6
- package/src/interfaces/web/src/components/code/CodeArtifactsTab.tsx +145 -2
- package/src/interfaces/web/src/components/settings/RoutingPanel.tsx +236 -0
- package/src/interfaces/web/src/components/settings/providers/typeStyles.ts +44 -25
- package/src/interfaces/web/src/i18n/en.ts +47 -0
- package/src/interfaces/web/src/i18n/es.ts +47 -0
- package/src/interfaces/web/src/lib/api/artifacts.ts +38 -0
- package/src/interfaces/web/src/lib/api/engines.ts +14 -0
- package/src/interfaces/web/src/main.tsx +5 -0
- package/src/interfaces/web/src/screens/base/ModelsTab.tsx +4 -2
- package/src/interfaces/web/src/types/daemon.ts +16 -0
- package/src/interfaces/web/dist/assets/index-BuII-tAi.css +0 -1
- package/src/interfaces/web/dist/assets/index-CFcs16SV.js +0 -778
- package/src/interfaces/web/dist/assets/index-CFcs16SV.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentprojectcontext/apx",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.67.0",
|
|
4
4
|
"description": "APX — unified CLI + daemon for the Agent Project Context (APC) standard.",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
"bin": {
|
|
16
16
|
"apx": "./src/interfaces/cli/index.js",
|
|
17
17
|
"apx-daemon": "./src/host/daemon/index.js",
|
|
18
|
-
"apx-mcp": "./src/interfaces/mcp-server/index.js"
|
|
18
|
+
"apx-mcp": "./src/interfaces/mcp-server/index.js",
|
|
19
|
+
"apx-acp": "./src/interfaces/acp/index.js"
|
|
19
20
|
},
|
|
20
21
|
"files": [
|
|
21
22
|
"src/",
|
package/skills/apx/SKILL.md
CHANGED
|
@@ -57,6 +57,9 @@ apx memory <agent-slug> --append "<fact>"
|
|
|
57
57
|
# Observe activity
|
|
58
58
|
apx messages tail
|
|
59
59
|
apx messages chat --channel <name> -n 20
|
|
60
|
+
|
|
61
|
+
# Protocol bridges (spawned by clients, not run interactively)
|
|
62
|
+
apx acp # Agent Client Protocol server on stdio (Zed, JetBrains, ...)
|
|
60
63
|
```
|
|
61
64
|
|
|
62
65
|
---
|
package/src/core/agent/index.js
CHANGED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Goal-completion judge loop (OpenHands Critic / iterative-refinement
|
|
2
|
+
// pattern). After a completion-contract run declares done, an LLM judge scores
|
|
3
|
+
// how likely the ORIGINAL goal is actually met (0..1). Below the threshold,
|
|
4
|
+
// the agent gets a follow-up verification note and continues — bounded by
|
|
5
|
+
// max_iterations so a harsh judge can't spin forever. Opt-in and scoped to
|
|
6
|
+
// completion-contract surfaces (coding turns), where "done" is checkable.
|
|
7
|
+
import { callEngine } from "../engines/index.js";
|
|
8
|
+
|
|
9
|
+
export function judgeConfig(globalConfig) {
|
|
10
|
+
const raw = globalConfig?.super_agent?.judge || {};
|
|
11
|
+
const threshold = Number(raw.success_threshold);
|
|
12
|
+
const iters = parseInt(raw.max_iterations, 10);
|
|
13
|
+
return {
|
|
14
|
+
enabled: raw.enabled === true,
|
|
15
|
+
success_threshold:
|
|
16
|
+
Number.isFinite(threshold) && threshold > 0 && threshold <= 1 ? threshold : 0.6,
|
|
17
|
+
max_iterations: Number.isFinite(iters) && iters > 0 ? Math.min(iters, 5) : 2,
|
|
18
|
+
model: typeof raw.model === "string" ? raw.model : "",
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const JUDGE_SYSTEM =
|
|
23
|
+
"You are a strict completion judge for an autonomous agent. You read the " +
|
|
24
|
+
"user's original request and the agent's final report plus action trace, and " +
|
|
25
|
+
"estimate the probability that the request is FULLY satisfied. Judge only " +
|
|
26
|
+
"what the evidence supports: claims without a matching action in the trace " +
|
|
27
|
+
"count against completion. Answer with STRICT JSON only.";
|
|
28
|
+
|
|
29
|
+
function judgePrompt({ goal, finalText, traceSummary }) {
|
|
30
|
+
return [
|
|
31
|
+
"ORIGINAL REQUEST:",
|
|
32
|
+
goal,
|
|
33
|
+
"",
|
|
34
|
+
"AGENT'S FINAL REPORT:",
|
|
35
|
+
finalText || "(empty)",
|
|
36
|
+
"",
|
|
37
|
+
"ACTION TRACE (tool, outcome preview):",
|
|
38
|
+
traceSummary || "(no tools ran)",
|
|
39
|
+
"",
|
|
40
|
+
'Reply with STRICT JSON, nothing else: {"score": <0..1 probability the request is fully satisfied>, "reasoning": "<one dense sentence>", "missing": ["<unmet requirement>", ...]}',
|
|
41
|
+
].join("\n");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function summarizeTraceForJudge(trace, { maxItems = 20 } = {}) {
|
|
45
|
+
if (!Array.isArray(trace) || trace.length === 0) return "";
|
|
46
|
+
return trace
|
|
47
|
+
.slice(-maxItems)
|
|
48
|
+
.map((t) => {
|
|
49
|
+
const r = t?.result;
|
|
50
|
+
const preview =
|
|
51
|
+
typeof r === "string"
|
|
52
|
+
? r
|
|
53
|
+
: r && typeof r === "object"
|
|
54
|
+
? (r.error ? `error: ${r.error}` : JSON.stringify(r))
|
|
55
|
+
: String(r);
|
|
56
|
+
return `- ${t.tool}: ${String(preview).slice(0, 160)}`;
|
|
57
|
+
})
|
|
58
|
+
.join("\n");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function parseVerdict(text) {
|
|
62
|
+
const raw = String(text || "");
|
|
63
|
+
const start = raw.indexOf("{");
|
|
64
|
+
if (start === -1) return null;
|
|
65
|
+
// Walk to the matching close brace so trailing prose can't break the parse.
|
|
66
|
+
let depth = 0;
|
|
67
|
+
let end = -1;
|
|
68
|
+
for (let i = start; i < raw.length; i++) {
|
|
69
|
+
if (raw[i] === "{") depth++;
|
|
70
|
+
else if (raw[i] === "}") {
|
|
71
|
+
depth--;
|
|
72
|
+
if (depth === 0) { end = i; break; }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (end === -1) return null;
|
|
76
|
+
try {
|
|
77
|
+
const obj = JSON.parse(raw.slice(start, end + 1));
|
|
78
|
+
const score = Number(obj.score);
|
|
79
|
+
if (!Number.isFinite(score)) return null;
|
|
80
|
+
return {
|
|
81
|
+
score: Math.max(0, Math.min(1, score)),
|
|
82
|
+
reasoning: typeof obj.reasoning === "string" ? obj.reasoning : "",
|
|
83
|
+
missing: Array.isArray(obj.missing) ? obj.missing.filter((m) => typeof m === "string") : [],
|
|
84
|
+
};
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Score a finished run against its goal. Returns {score, reasoning, missing}
|
|
92
|
+
* or null when the judge is unusable (engine down, unparseable reply) — the
|
|
93
|
+
* caller treats null as "accept the result", never as a failure.
|
|
94
|
+
*/
|
|
95
|
+
export async function judgeCompletion({ goal, result, globalConfig, callEngineFn = callEngine }) {
|
|
96
|
+
const cfg = judgeConfig(globalConfig);
|
|
97
|
+
const modelId = cfg.model || globalConfig?.super_agent?.model || "";
|
|
98
|
+
if (!modelId) return null;
|
|
99
|
+
try {
|
|
100
|
+
const r = await callEngineFn({
|
|
101
|
+
modelId,
|
|
102
|
+
system: JUDGE_SYSTEM,
|
|
103
|
+
messages: [
|
|
104
|
+
{
|
|
105
|
+
role: "user",
|
|
106
|
+
content: judgePrompt({
|
|
107
|
+
goal,
|
|
108
|
+
finalText: result?.text || "",
|
|
109
|
+
traceSummary: summarizeTraceForJudge(result?.trace),
|
|
110
|
+
}),
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
config: globalConfig,
|
|
114
|
+
maxTokens: 500,
|
|
115
|
+
temperature: 0,
|
|
116
|
+
});
|
|
117
|
+
return parseVerdict(r?.text);
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// In-band note, adapted from OpenHands CriticBase.get_followup_prompt: shapes
|
|
124
|
+
// behavior, never words. The model must re-verify against the ORIGINAL
|
|
125
|
+
// request, not against the judge's phrasing.
|
|
126
|
+
export function buildJudgeFollowup(verdict, iteration) {
|
|
127
|
+
const pct = Math.round((verdict?.score ?? 0) * 100);
|
|
128
|
+
const missing = (verdict?.missing || []).slice(0, 5);
|
|
129
|
+
return [
|
|
130
|
+
`[Internal verification note — this is NOT from the user. An automated completion check scored this turn ${pct}% likely complete (verification round ${iteration}).`,
|
|
131
|
+
verdict?.reasoning ? `Judge's reasoning: ${verdict.reasoning}` : "",
|
|
132
|
+
missing.length ? `Possibly unmet: ${missing.map((m) => `"${m}"`).join(", ")}.` : "",
|
|
133
|
+
"Re-read the user's ORIGINAL request, verify each requirement against what you actually did (check the tool results, don't assume), complete whatever is genuinely missing, then finish. If everything IS already complete, finish with a summary stating precisely why each requirement is met.]",
|
|
134
|
+
]
|
|
135
|
+
.filter(Boolean)
|
|
136
|
+
.join("\n");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Refinement driver, dependency-injected so surfaces and tests supply their
|
|
141
|
+
* own judge/runner. `judgeFn(result)` → verdict|null; `runFollowup(followupPrompt,
|
|
142
|
+
* result)` → next run result. Merges usage and traces across rounds and
|
|
143
|
+
* attaches the verdict trail as `result.judge`.
|
|
144
|
+
*/
|
|
145
|
+
export async function applyJudgeLoop({ initialResult, cfg, judgeFn, runFollowup, onEvent = null }) {
|
|
146
|
+
let result = initialResult;
|
|
147
|
+
const trail = [];
|
|
148
|
+
for (let i = 1; i <= cfg.max_iterations; i++) {
|
|
149
|
+
const verdict = await judgeFn(result);
|
|
150
|
+
if (verdict) {
|
|
151
|
+
trail.push({ iteration: i, ...verdict });
|
|
152
|
+
if (typeof onEvent === "function") {
|
|
153
|
+
await onEvent({
|
|
154
|
+
type: "judge_verdict",
|
|
155
|
+
iteration: i,
|
|
156
|
+
score: verdict.score,
|
|
157
|
+
reasoning: verdict.reasoning,
|
|
158
|
+
passed: verdict.score >= cfg.success_threshold,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (!verdict || verdict.score >= cfg.success_threshold) break;
|
|
163
|
+
const next = await runFollowup(buildJudgeFollowup(verdict, i), result);
|
|
164
|
+
result = {
|
|
165
|
+
...next,
|
|
166
|
+
usage: {
|
|
167
|
+
input_tokens: (result.usage?.input_tokens || 0) + (next.usage?.input_tokens || 0),
|
|
168
|
+
output_tokens: (result.usage?.output_tokens || 0) + (next.usage?.output_tokens || 0),
|
|
169
|
+
},
|
|
170
|
+
trace: [...(result.trace || []), ...(next.trace || [])],
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
return trail.length ? { ...result, judge: trail } : result;
|
|
174
|
+
}
|
|
@@ -133,10 +133,91 @@ export function isFallbackEnabled(globalConfig) {
|
|
|
133
133
|
return fb.enabled !== false;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Content-based routing (OpenHands RouterLLM pattern). Static engine routing
|
|
138
|
+
// picks ONE model per deployment; these rules inspect the actual turn —
|
|
139
|
+
// images, prompt/context size, channel, keywords — and prefer a different
|
|
140
|
+
// model for it. The preferred model still goes through the same health check
|
|
141
|
+
// and falls back down the regular chain when unavailable, so a routing rule
|
|
142
|
+
// can never strand a turn on a dead provider.
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
export function routingConfig(globalConfig) {
|
|
146
|
+
const raw = globalConfig?.super_agent?.routing || {};
|
|
147
|
+
return {
|
|
148
|
+
enabled: raw.enabled === true,
|
|
149
|
+
rules: Array.isArray(raw.rules) ? raw.rules : [],
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Multimodal content shows up as parts arrays on message content (engine
|
|
154
|
+
// adapters and the Telegram photo flow use type "image"/"image_url").
|
|
155
|
+
function contentHasImage(content) {
|
|
156
|
+
if (!Array.isArray(content)) return false;
|
|
157
|
+
return content.some(
|
|
158
|
+
(p) => p && (p.type === "image" || p.type === "image_url" || p.type === "input_image")
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Evaluate `super_agent.routing.rules` in order against the turn's features;
|
|
164
|
+
* first full match wins. Each rule: `{ model: "<provider>:<model>", when: {
|
|
165
|
+
* has_image?, min_prompt_chars?, max_prompt_chars?, min_context_chars?,
|
|
166
|
+
* channels?: [], keywords?: [] } }`. All conditions in `when` must hold
|
|
167
|
+
* (AND); an empty `when` matches every turn. Returns `{model, ruleIndex}` or
|
|
168
|
+
* null (no rules, disabled, or nothing matched).
|
|
169
|
+
*/
|
|
170
|
+
export function selectModelByRules(
|
|
171
|
+
{ prompt = "", previousMessages = [], channel = "", channelMeta = {} } = {},
|
|
172
|
+
globalConfig
|
|
173
|
+
) {
|
|
174
|
+
const cfg = routingConfig(globalConfig);
|
|
175
|
+
if (!cfg.enabled || cfg.rules.length === 0) return null;
|
|
176
|
+
|
|
177
|
+
const promptText = typeof prompt === "string" ? prompt : "";
|
|
178
|
+
const messages = Array.isArray(previousMessages) ? previousMessages : [];
|
|
179
|
+
const hasImage =
|
|
180
|
+
channelMeta?.has_image === true ||
|
|
181
|
+
contentHasImage(prompt) ||
|
|
182
|
+
messages.some((m) => contentHasImage(m?.content));
|
|
183
|
+
let contextChars = 0;
|
|
184
|
+
for (const m of messages) {
|
|
185
|
+
const c = m?.content;
|
|
186
|
+
if (typeof c === "string") contextChars += c.length;
|
|
187
|
+
else if (c != null) {
|
|
188
|
+
try { contextChars += JSON.stringify(c).length; } catch { /* unserializable — skip */ }
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
for (let i = 0; i < cfg.rules.length; i++) {
|
|
193
|
+
const rule = cfg.rules[i] || {};
|
|
194
|
+
const model = rule.model;
|
|
195
|
+
if (typeof model !== "string" || !model.includes(":")) continue;
|
|
196
|
+
const when = rule.when || {};
|
|
197
|
+
|
|
198
|
+
if (when.has_image === true && !hasImage) continue;
|
|
199
|
+
if (when.has_image === false && hasImage) continue;
|
|
200
|
+
if (Number.isFinite(when.min_prompt_chars) && promptText.length < when.min_prompt_chars) continue;
|
|
201
|
+
if (Number.isFinite(when.max_prompt_chars) && promptText.length > when.max_prompt_chars) continue;
|
|
202
|
+
if (Number.isFinite(when.min_context_chars) && contextChars < when.min_context_chars) continue;
|
|
203
|
+
if (Array.isArray(when.channels) && when.channels.length > 0 && !when.channels.includes(channel)) continue;
|
|
204
|
+
if (Array.isArray(when.keywords) && when.keywords.length > 0) {
|
|
205
|
+
const low = promptText.toLowerCase();
|
|
206
|
+
const hit = when.keywords.some(
|
|
207
|
+
(k) => typeof k === "string" && k.trim().length >= 2 && low.includes(k.toLowerCase())
|
|
208
|
+
);
|
|
209
|
+
if (!hit) continue;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return { model, ruleIndex: i };
|
|
213
|
+
}
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
136
217
|
/**
|
|
137
218
|
* Pick first healthy model following configured provider order.
|
|
138
219
|
*/
|
|
139
|
-
export async function resolveActiveModel(globalConfig, { overrideModel = null, timeoutMs } = {}) {
|
|
220
|
+
export async function resolveActiveModel(globalConfig, { overrideModel = null, preferredModel = null, timeoutMs } = {}) {
|
|
140
221
|
if (overrideModel) {
|
|
141
222
|
const { provider } = parseModelId(overrideModel);
|
|
142
223
|
return {
|
|
@@ -152,12 +233,20 @@ export async function resolveActiveModel(globalConfig, { overrideModel = null, t
|
|
|
152
233
|
const fb = sa.model_fallback || {};
|
|
153
234
|
const healthMs = timeoutMs ?? fb.health_timeout_ms ?? 800;
|
|
154
235
|
const tried = [];
|
|
236
|
+
// Content routing (selectModelByRules) prefers a model for THIS turn. It
|
|
237
|
+
// leads the chain but is not forced: unhealthy → regular chain takes over.
|
|
238
|
+
const preferred =
|
|
239
|
+
typeof preferredModel === "string" && preferredModel.includes(":") ? preferredModel : null;
|
|
155
240
|
|
|
156
241
|
if (isFallbackEnabled(globalConfig)) {
|
|
157
|
-
// Build the full chain:
|
|
158
|
-
// Each entry is a fully-qualified
|
|
242
|
+
// Build the full chain: preferred (content-routed) first, then primary,
|
|
243
|
+
// then the fallback list, deduped. Each entry is a fully-qualified
|
|
244
|
+
// "<provider>:<model>" string.
|
|
159
245
|
const chain = [];
|
|
160
|
-
if (
|
|
246
|
+
if (preferred) chain.push(preferred);
|
|
247
|
+
if (typeof sa.model === "string" && sa.model.includes(":") && !chain.includes(sa.model)) {
|
|
248
|
+
chain.push(sa.model);
|
|
249
|
+
}
|
|
161
250
|
for (const m of fallbackModels(globalConfig)) {
|
|
162
251
|
if (!chain.includes(m)) chain.push(m);
|
|
163
252
|
}
|
|
@@ -185,11 +274,24 @@ export async function resolveActiveModel(globalConfig, { overrideModel = null, t
|
|
|
185
274
|
return {
|
|
186
275
|
modelId,
|
|
187
276
|
provider,
|
|
188
|
-
fromFallback: !isPrimary,
|
|
277
|
+
fromFallback: !isPrimary && modelId !== preferred,
|
|
278
|
+
...(modelId === preferred ? { routedBy: "content_rules" } : {}),
|
|
189
279
|
tried,
|
|
190
280
|
};
|
|
191
281
|
}
|
|
192
282
|
}
|
|
283
|
+
} else if (preferred) {
|
|
284
|
+
// No fallback router: honor the routing rule directly (same trust level
|
|
285
|
+
// as the primary model, which is also unchecked in this branch).
|
|
286
|
+
const { provider } = parseModelId(preferred);
|
|
287
|
+
return {
|
|
288
|
+
modelId: preferred,
|
|
289
|
+
provider,
|
|
290
|
+
fromFallback: false,
|
|
291
|
+
forced: true,
|
|
292
|
+
routedBy: "content_rules",
|
|
293
|
+
tried: [{ provider, modelId: preferred, healthy: true, reason: "content_rules" }],
|
|
294
|
+
};
|
|
193
295
|
}
|
|
194
296
|
|
|
195
297
|
if (sa.model) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
MODE: build. Make the changes directly using your file and shell tools (read_file, write_file, edit_file, run_shell, …). Do not ask for confirmation and do not stop after one step — keep calling tools until the entire task is done, then briefly summarize what you changed and why. Prefer surgical edits over rewrites. When the user asks for a reusable script, snippet, or 'artifact' (something they want to keep and run later), put it under `artifacts/<name>` inside the project — it then shows up in the Artifacts tab. Don't drop reusable scripts at the project root. If a parameter you need is missing (API key, app id, target URL, …), call `ask_questions` ONCE with all your questions and stop — control returns to the user. Do not call ask_questions again in the same turn; you'll just get the same blank state back. Each question can be a string (free-text answer) OR an object {question, options:[{label, description}], multiSelect} for choices. Prefer 2–4 mutually-exclusive options when a question has a natural shortlist (yes/no, which-of-these, …); leave options empty for open-ended answers (API keys, names, free-form ideas). If the previous assistant turn already asked these same questions and the current user message is the compiled answers, DO NOT call ask_questions again — process the answers and proceed with the task.
|
|
1
|
+
MODE: build. Make the changes directly using your file and shell tools (read_file, write_file, edit_file, run_shell, …). Do not ask for confirmation and do not stop after one step — keep calling tools until the entire task is done, then briefly summarize what you changed and why. Prefer surgical edits over rewrites. When the user asks for a reusable script, snippet, or 'artifact' (something they want to keep and run later), put it under `artifacts/<name>` inside the project — it then shows up in the Artifacts tab. Don't drop reusable scripts at the project root. When an artifact is something visual the user should SEE (an HTML page, a single-file React component, a dashboard), preview it with `run_shell`: `apx artifact preview <name>` serves it on an ephemeral local URL with live-reload, and `apx artifact share <name>` also returns a temporary public URL to share — give the user the printed link. If a parameter you need is missing (API key, app id, target URL, …), call `ask_questions` ONCE with all your questions and stop — control returns to the user. Do not call ask_questions again in the same turn; you'll just get the same blank state back. Each question can be a string (free-text answer) OR an object {question, options:[{label, description}], multiSelect} for choices. Prefer 2–4 mutually-exclusive options when a question has a natural shortlist (yes/no, which-of-these, …); leave options empty for open-ended answers (API keys, names, free-form ideas). If the previous assistant turn already asked these same questions and the current user message is the compiled answers, DO NOT call ask_questions again — process the answers and proceed with the task.
|
|
@@ -8,6 +8,19 @@ import { MAX_TOOL_ITERS, ACK_ONLY_TOOLS, MAX_CONSECUTIVE_ACKS, TURN_ENDING_TOOLS
|
|
|
8
8
|
import { pseudoToolSystem, shouldRetryWithPseudoTools } from "./tools/pseudo-tools.js";
|
|
9
9
|
import { filterToolSchemas } from "./tools-overlap.js";
|
|
10
10
|
import { isRetryableEngineError, shortRetryReason } from "./retry.js";
|
|
11
|
+
import {
|
|
12
|
+
securityRiskConfig,
|
|
13
|
+
withSecurityRiskField,
|
|
14
|
+
popSecurityRisk,
|
|
15
|
+
shouldConfirmRisk,
|
|
16
|
+
} from "./security.js";
|
|
17
|
+
import { buildConfirmDescription } from "../confirmation/index.js";
|
|
18
|
+
import { PERMISSION_MODES } from "../constants/permissions.js";
|
|
19
|
+
import {
|
|
20
|
+
stuckDetectionConfig,
|
|
21
|
+
createStuckDetector,
|
|
22
|
+
stuckNudgeSignal,
|
|
23
|
+
} from "./stuck-detector.js";
|
|
11
24
|
|
|
12
25
|
async function emitProgress(onEvent, event) {
|
|
13
26
|
if (typeof onEvent !== "function") return;
|
|
@@ -120,6 +133,9 @@ export async function runAgent({
|
|
|
120
133
|
prompt,
|
|
121
134
|
previousMessages = [],
|
|
122
135
|
overrideModel = null,
|
|
136
|
+
// Content-routed model for this turn (selectModelByRules). Unlike
|
|
137
|
+
// overrideModel it is health-checked and falls back down the chain.
|
|
138
|
+
preferredModel = null,
|
|
123
139
|
toolSchemas,
|
|
124
140
|
makeToolHandlers,
|
|
125
141
|
toolHandlerCtx,
|
|
@@ -144,7 +160,7 @@ export async function runAgent({
|
|
|
144
160
|
// never end the turn by narrating the next step. Language-agnostic by design.
|
|
145
161
|
completionContract = false,
|
|
146
162
|
}) {
|
|
147
|
-
const routing = await resolveActiveModel(globalConfig, { overrideModel });
|
|
163
|
+
const routing = await resolveActiveModel(globalConfig, { overrideModel, preferredModel });
|
|
148
164
|
// Mutable: lazy-retry can rotate to a different model mid-loop on 429/413/5xx.
|
|
149
165
|
let activeModel = routing.modelId;
|
|
150
166
|
|
|
@@ -161,12 +177,13 @@ export async function runAgent({
|
|
|
161
177
|
return true;
|
|
162
178
|
});
|
|
163
179
|
|
|
164
|
-
if (routing.fromFallback) {
|
|
180
|
+
if (routing.fromFallback || routing.routedBy) {
|
|
165
181
|
await emitProgress(onEvent, {
|
|
166
182
|
type: "model_routed",
|
|
167
183
|
model: activeModel,
|
|
168
184
|
provider: routing.provider,
|
|
169
|
-
from_fallback: true,
|
|
185
|
+
from_fallback: routing.fromFallback === true,
|
|
186
|
+
...(routing.routedBy ? { routed_by: routing.routedBy } : {}),
|
|
170
187
|
tried: routing.tried,
|
|
171
188
|
});
|
|
172
189
|
}
|
|
@@ -194,6 +211,30 @@ export async function runAgent({
|
|
|
194
211
|
effectiveSchemas = [...effectiveSchemas, FINISH_TOOL_SCHEMA];
|
|
195
212
|
}
|
|
196
213
|
|
|
214
|
+
// Inline security-risk analysis: every eligible tool schema gains a required
|
|
215
|
+
// `security_risk` enum the model fills as part of the call itself (no second
|
|
216
|
+
// LLM pass). The loop extracts the grade below and the ConfirmRisky policy
|
|
217
|
+
// decides whether the call pauses for human approval.
|
|
218
|
+
const riskCfg = securityRiskConfig(globalConfig);
|
|
219
|
+
const permissionMode = globalConfig?.super_agent?.permission_mode || "";
|
|
220
|
+
const riskGateOn = riskCfg.enabled;
|
|
221
|
+
// In `total` (full trust) the risk gate acts ONLY as a safety floor: HIGH-risk
|
|
222
|
+
// actions still confirm, everything below runs free. That's the value the
|
|
223
|
+
// permission mode alone can't give — it gates by tool identity, this gates by
|
|
224
|
+
// the model's own judgment of THIS action's severity. In automatico/permiso
|
|
225
|
+
// the configured confirm_at applies.
|
|
226
|
+
const effectiveRiskCfg =
|
|
227
|
+
permissionMode === PERMISSION_MODES.TOTAL
|
|
228
|
+
? { ...riskCfg, confirm_at: "HIGH", confirm_unknown: false }
|
|
229
|
+
: riskCfg;
|
|
230
|
+
if (riskGateOn) {
|
|
231
|
+
effectiveSchemas = withSecurityRiskField(effectiveSchemas);
|
|
232
|
+
// Handshake with createPermissionGuard: outside `total`, the analyzer owns
|
|
233
|
+
// dangerous-call gating so the static dangerous-flag branch stands down.
|
|
234
|
+
// (In `total` the guard returns early anyway, so this is a no-op there.)
|
|
235
|
+
if (toolHandlerCtx) toolHandlerCtx.securityRiskActive = true;
|
|
236
|
+
}
|
|
237
|
+
|
|
197
238
|
const rawHandlers = makeToolHandlers(toolHandlerCtx);
|
|
198
239
|
const handlers = suppressed.size > 0
|
|
199
240
|
? new Proxy(rawHandlers, {
|
|
@@ -224,7 +265,11 @@ export async function runAgent({
|
|
|
224
265
|
if (n && !seen.has(n)) { additions.push(sc); seen.add(n); }
|
|
225
266
|
}
|
|
226
267
|
toolSession.pending = [];
|
|
227
|
-
if (additions.length > 0)
|
|
268
|
+
if (additions.length > 0) {
|
|
269
|
+
effectiveSchemas = effectiveSchemas.concat(
|
|
270
|
+
riskGateOn ? withSecurityRiskField(additions) : additions
|
|
271
|
+
);
|
|
272
|
+
}
|
|
228
273
|
};
|
|
229
274
|
|
|
230
275
|
const conversation = [...previousMessages, { role: "user", content: prompt }];
|
|
@@ -281,6 +326,23 @@ export async function runAgent({
|
|
|
281
326
|
}
|
|
282
327
|
};
|
|
283
328
|
|
|
329
|
+
// Stuck detection: catches the loops the side-effect dedupe can't — a
|
|
330
|
+
// read-only call repeated with identical results, or the same call erroring
|
|
331
|
+
// over and over. First trigger = in-band nudge; second = force the tool-free
|
|
332
|
+
// wrap-up so the turn closes with a model-authored status instead of burning
|
|
333
|
+
// the rest of the budget.
|
|
334
|
+
const stuckCfg = stuckDetectionConfig(globalConfig);
|
|
335
|
+
const stuckDetector = createStuckDetector(stuckCfg);
|
|
336
|
+
let stuckNudged = false;
|
|
337
|
+
let forceWrapUp = false;
|
|
338
|
+
const safeSig = (v) => {
|
|
339
|
+
try {
|
|
340
|
+
return JSON.stringify(v) ?? "";
|
|
341
|
+
} catch {
|
|
342
|
+
return "<unserializable>";
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
|
|
284
346
|
// Engine call wrapped with lazy retry: on 413/429/5xx/rate-limit/etc, try
|
|
285
347
|
// the next model in `retryChain` instead of bubbling. Stops when the chain
|
|
286
348
|
// is exhausted; non-retryable errors (auth, bad payload) throw immediately.
|
|
@@ -316,8 +378,12 @@ export async function runAgent({
|
|
|
316
378
|
// STRUCTURE (no tools this step) + an in-band directive turn (WRAPUP_SIGNAL);
|
|
317
379
|
// the wording is entirely the model's. Coding surfaces keep their finish-tool flow, so
|
|
318
380
|
// this never applies under completionContract.
|
|
381
|
+
// forceWrapUp (stuck abort) overrides the contract: a stuck model under
|
|
382
|
+
// toolChoice:"required" would only repeat itself, so we withhold tools and
|
|
383
|
+
// make it close the turn in prose either way.
|
|
319
384
|
const isFinalWrapUp =
|
|
320
|
-
|
|
385
|
+
effectiveSchemas.length > 0 &&
|
|
386
|
+
((!useContract && iter === maxIters - 1) || forceWrapUp);
|
|
321
387
|
await emitProgress(onEvent, {
|
|
322
388
|
type: isFinalWrapUp ? "final_wrapup" : "model_start",
|
|
323
389
|
iteration: iter + 1,
|
|
@@ -426,6 +492,9 @@ export async function runAgent({
|
|
|
426
492
|
try { args = JSON.parse(args); } catch { args = {}; }
|
|
427
493
|
}
|
|
428
494
|
args = args || {};
|
|
495
|
+
// Pop the model's own risk grade BEFORE the handler sees the args — the
|
|
496
|
+
// field belongs to the loop, not to any tool's contract.
|
|
497
|
+
const securityRisk = riskGateOn ? popSecurityRisk(args) : null;
|
|
429
498
|
|
|
430
499
|
// Completion contract: `finish` declares the task done. Capture its
|
|
431
500
|
// summary as the final text and stop processing the rest of this turn.
|
|
@@ -438,7 +507,13 @@ export async function runAgent({
|
|
|
438
507
|
const traceId = `${iter + 1}:${trace.length + 1}`;
|
|
439
508
|
await emitProgress(onEvent, {
|
|
440
509
|
type: "tool_start",
|
|
441
|
-
trace: {
|
|
510
|
+
trace: {
|
|
511
|
+
id: traceId,
|
|
512
|
+
tool: name,
|
|
513
|
+
args,
|
|
514
|
+
pending: true,
|
|
515
|
+
...(securityRisk ? { security_risk: securityRisk } : {}),
|
|
516
|
+
},
|
|
442
517
|
iteration: iter + 1,
|
|
443
518
|
});
|
|
444
519
|
// Dedupe identical side-effecting calls within this turn.
|
|
@@ -456,19 +531,65 @@ export async function runAgent({
|
|
|
456
531
|
iteration: iter + 1,
|
|
457
532
|
});
|
|
458
533
|
} else {
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
534
|
+
// ConfirmRisky gate: pause on the model's own grade before executing.
|
|
535
|
+
// A decline becomes a normal error observation — the model sees the
|
|
536
|
+
// rejection and can re-plan, mirroring OpenHands' rejection flow.
|
|
537
|
+
let riskDenied = null;
|
|
538
|
+
if (riskGateOn && shouldConfirmRisk(securityRisk, effectiveRiskCfg)) {
|
|
539
|
+
const description = buildConfirmDescription(name, args);
|
|
540
|
+
const requestConfirmation = toolHandlerCtx?.requestConfirmation;
|
|
541
|
+
if (typeof requestConfirmation !== "function") {
|
|
542
|
+
riskDenied = `Action requires user confirmation (security risk ${securityRisk}): ${description}`;
|
|
543
|
+
} else {
|
|
544
|
+
await emitProgress(onEvent, {
|
|
545
|
+
type: "security_confirmation",
|
|
546
|
+
trace: { id: traceId, tool: name, risk: securityRisk },
|
|
547
|
+
iteration: iter + 1,
|
|
548
|
+
});
|
|
549
|
+
let approved = false;
|
|
550
|
+
try {
|
|
551
|
+
approved = await requestConfirmation(name, args, `[risk: ${securityRisk}] ${description}`);
|
|
552
|
+
} catch {
|
|
553
|
+
approved = false;
|
|
554
|
+
}
|
|
555
|
+
if (approved && toolHandlerCtx) toolHandlerCtx.securityGateCleared = true;
|
|
556
|
+
if (!approved) {
|
|
557
|
+
riskDenied = `User did not confirm (security risk ${securityRisk}): ${description}`;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
if (riskDenied) {
|
|
562
|
+
toolResult = { error: riskDenied };
|
|
563
|
+
} else {
|
|
564
|
+
try {
|
|
565
|
+
const handler = handlers[name];
|
|
566
|
+
toolResult = handler ? await handler(args) : { error: `unknown tool: ${name}` };
|
|
567
|
+
} catch (e) {
|
|
568
|
+
toolResult = { error: e.message };
|
|
569
|
+
} finally {
|
|
570
|
+
if (toolHandlerCtx) toolHandlerCtx.securityGateCleared = false;
|
|
571
|
+
}
|
|
464
572
|
}
|
|
465
573
|
if (sig) sideEffectExecuted.set(sig, summarizeForTrace(toolResult));
|
|
466
574
|
}
|
|
467
575
|
|
|
468
|
-
const traceItem = {
|
|
576
|
+
const traceItem = {
|
|
577
|
+
id: traceId,
|
|
578
|
+
tool: name,
|
|
579
|
+
args,
|
|
580
|
+
result: summarizeForTrace(toolResult),
|
|
581
|
+
...(securityRisk ? { security_risk: securityRisk } : {}),
|
|
582
|
+
};
|
|
469
583
|
trace.push(traceItem);
|
|
470
584
|
await emitProgress(onEvent, { type: "tool_result", trace: traceItem, iteration: iter + 1 });
|
|
471
585
|
|
|
586
|
+
stuckDetector.record({
|
|
587
|
+
tool: name,
|
|
588
|
+
argsSig: safeSig(args),
|
|
589
|
+
resultSig: safeSig(traceItem.result),
|
|
590
|
+
isError: !!(toolResult && typeof toolResult === "object" && toolResult.error),
|
|
591
|
+
});
|
|
592
|
+
|
|
472
593
|
// Groq (and strict OpenAI) require tool_call_id to be present and
|
|
473
594
|
// match the id of the tool_call in the previous assistant message.
|
|
474
595
|
// Real engines populate it; the pseudo-tool parser also assigns one
|
|
@@ -529,6 +650,22 @@ export async function runAgent({
|
|
|
529
650
|
} else {
|
|
530
651
|
ackOnlyStreak = 0;
|
|
531
652
|
}
|
|
653
|
+
|
|
654
|
+
// Stuck escalation: first detection nudges (in-band note, detector reset so
|
|
655
|
+
// only FRESH repetitions count again); a second detection means the nudge
|
|
656
|
+
// didn't land — stop spending budget and force the wrap-up close.
|
|
657
|
+
const stuck = stuckDetector.check();
|
|
658
|
+
if (stuck) {
|
|
659
|
+
if (!stuckNudged) {
|
|
660
|
+
stuckNudged = true;
|
|
661
|
+
stuckDetector.reset();
|
|
662
|
+
await emitProgress(onEvent, { type: "stuck_detected", ...stuck, iteration: iter + 1 });
|
|
663
|
+
conversation.push({ role: "user", content: stuckNudgeSignal(stuck) });
|
|
664
|
+
} else {
|
|
665
|
+
await emitProgress(onEvent, { type: "stuck_abort", ...stuck, iteration: iter + 1 });
|
|
666
|
+
forceWrapUp = true;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
532
669
|
}
|
|
533
670
|
|
|
534
671
|
return {
|