@agentprojectcontext/apx 1.66.0 → 1.68.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.
Files changed (61) hide show
  1. package/package.json +3 -2
  2. package/skills/apx/SKILL.md +3 -0
  3. package/src/core/agent/index.js +2 -0
  4. package/src/core/agent/judge.js +174 -0
  5. package/src/core/agent/model-router.js +107 -5
  6. package/src/core/agent/prompts/modes/code-build.md +1 -1
  7. package/src/core/agent/run-agent.js +149 -12
  8. package/src/core/agent/security.js +97 -0
  9. package/src/core/agent/stuck-detector.js +89 -0
  10. package/src/core/agent/super-agent.js +58 -17
  11. package/src/core/agent/tools/handlers/run-subagent.js +117 -0
  12. package/src/core/agent/tools/helpers.js +11 -1
  13. package/src/core/agent/tools/names.js +2 -0
  14. package/src/core/agent/tools/registry.js +10 -0
  15. package/src/core/artifacts/preview.js +392 -0
  16. package/src/core/artifacts/tunnel.js +169 -0
  17. package/src/core/config/index.js +61 -0
  18. package/src/core/config/redact.js +44 -0
  19. package/src/core/config/secret-values.js +132 -0
  20. package/src/core/engines/mock.js +15 -1
  21. package/src/core/logging.js +10 -3
  22. package/src/core/memory/compactor.js +65 -56
  23. package/src/core/memory/summarizer.js +125 -0
  24. package/src/core/stores/conversations-compactor.js +24 -31
  25. package/src/host/daemon/api/admin-config.js +5 -0
  26. package/src/host/daemon/api/artifact-preview.js +82 -0
  27. package/src/host/daemon/api/config.js +17 -5
  28. package/src/host/daemon/api/sessions.js +9 -0
  29. package/src/host/daemon/api/web.js +1 -1
  30. package/src/host/daemon/api.js +2 -0
  31. package/src/host/daemon/index.js +16 -1
  32. package/src/interfaces/acp/index.js +363 -0
  33. package/src/interfaces/acp/jsonrpc.js +180 -0
  34. package/src/interfaces/acp/session.js +205 -0
  35. package/src/interfaces/cli/commands/acp.js +10 -0
  36. package/src/interfaces/cli/commands/artifact.js +115 -0
  37. package/src/interfaces/cli/index.js +74 -0
  38. package/src/interfaces/web/dist/assets/index-D4BmWoDM.css +1 -0
  39. package/src/interfaces/web/dist/assets/index-vwd6yQVw.js +803 -0
  40. package/src/interfaces/web/dist/assets/index-vwd6yQVw.js.map +1 -0
  41. package/src/interfaces/web/dist/index.html +2 -2
  42. package/src/interfaces/web/package-lock.json +9 -9
  43. package/src/interfaces/web/src/components/code/CodeArtifactsTab.tsx +145 -2
  44. package/src/interfaces/web/src/components/config/ConfigTabsEditor.tsx +46 -31
  45. package/src/interfaces/web/src/components/config/project-config-sections.ts +9 -11
  46. package/src/interfaces/web/src/components/memory/MemoryBrowser.tsx +162 -0
  47. package/src/interfaces/web/src/components/settings/RoutingPanel.tsx +236 -0
  48. package/src/interfaces/web/src/i18n/en.ts +53 -0
  49. package/src/interfaces/web/src/i18n/es.ts +53 -0
  50. package/src/interfaces/web/src/lib/api/artifacts.ts +38 -0
  51. package/src/interfaces/web/src/lib/api/sessions.ts +2 -1
  52. package/src/interfaces/web/src/screens/ProjectScreen.tsx +50 -60
  53. package/src/interfaces/web/src/screens/base/ModelsTab.tsx +4 -2
  54. package/src/interfaces/web/src/screens/base/SessionsTab.tsx +11 -5
  55. package/src/interfaces/web/src/screens/project/ConfigTab.tsx +110 -25
  56. package/src/interfaces/web/src/screens/project/MemoriesTab.tsx +7 -128
  57. package/src/interfaces/web/src/screens/project/Overview.tsx +3 -2
  58. package/src/interfaces/web/src/types/daemon.ts +16 -0
  59. package/src/interfaces/web/dist/assets/index-BuII-tAi.css +0 -1
  60. package/src/interfaces/web/dist/assets/index-YmMRG--4.js +0 -778
  61. package/src/interfaces/web/dist/assets/index-YmMRG--4.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentprojectcontext/apx",
3
- "version": "1.66.0",
3
+ "version": "1.68.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/",
@@ -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
  ---
@@ -22,6 +22,8 @@ export {
22
22
  probeAllProviders,
23
23
  fallbackModels,
24
24
  isFallbackEnabled,
25
+ routingConfig,
26
+ selectModelByRules,
25
27
  DEFAULT_FALLBACK_ORDER,
26
28
  DEFAULT_FALLBACK_MODELS,
27
29
  } from "./model-router.js";
@@ -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: primary first, then the fallback list, deduped.
158
- // Each entry is a fully-qualified "<provider>:<model>" string.
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 (typeof sa.model === "string" && sa.model.includes(":")) chain.push(sa.model);
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) effectiveSchemas = effectiveSchemas.concat(additions);
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
- !useContract && effectiveSchemas.length > 0 && iter === maxIters - 1;
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: { id: traceId, tool: name, args, pending: true },
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
- try {
460
- const handler = handlers[name];
461
- toolResult = handler ? await handler(args) : { error: `unknown tool: ${name}` };
462
- } catch (e) {
463
- toolResult = { error: e.message };
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 = { id: traceId, tool: name, args, result: summarizeForTrace(toolResult) };
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 {