@agentprojectcontext/apx 1.32.2 → 1.33.1

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 (48) hide show
  1. package/package.json +1 -1
  2. package/skills/apc-context/SKILL.md +2 -5
  3. package/src/core/agent/prompts/action-discipline.md +12 -5
  4. package/src/core/agent/prompts/channels/telegram.md +9 -5
  5. package/src/core/apc/parser.js +1 -1
  6. package/src/core/apc/scaffold.js +3 -1
  7. package/src/core/apc/skill-sync.js +3 -1
  8. package/src/core/engines/gemini.js +28 -11
  9. package/src/core/engines/index.js +11 -1
  10. package/src/core/stores/code-sessions.js +4 -1
  11. package/src/host/daemon/api/artifacts.js +25 -0
  12. package/src/host/daemon/api/code.js +14 -1
  13. package/src/host/daemon/api/engines.js +31 -1
  14. package/src/host/daemon/api/exec.js +17 -2
  15. package/src/host/daemon/plugins/telegram/dispatch.js +573 -0
  16. package/src/host/daemon/plugins/telegram/helpers.js +130 -0
  17. package/src/host/daemon/plugins/telegram/index.js +19 -694
  18. package/src/interfaces/web/dist/assets/index-Aaiw8BZN.css +1 -0
  19. package/src/interfaces/web/dist/assets/index-DPqtjDjh.js +602 -0
  20. package/src/interfaces/web/dist/assets/index-DPqtjDjh.js.map +1 -0
  21. package/src/interfaces/web/dist/index.html +2 -2
  22. package/src/interfaces/web/package-lock.json +3 -3
  23. package/src/interfaces/web/src/App.tsx +3 -1
  24. package/src/interfaces/web/src/components/ModelCombobox.tsx +42 -7
  25. package/src/interfaces/web/src/components/UiSelect.tsx +12 -2
  26. package/src/interfaces/web/src/components/code/CodeArtifactsTab.tsx +253 -111
  27. package/src/interfaces/web/src/components/code/CodeChangesTab.tsx +10 -8
  28. package/src/interfaces/web/src/components/code/CodeComposer.tsx +20 -17
  29. package/src/interfaces/web/src/components/code/CodeContextTab.tsx +43 -18
  30. package/src/interfaces/web/src/components/code/CodeFileTree.tsx +212 -0
  31. package/src/interfaces/web/src/components/code/CodeFileViewer.tsx +121 -0
  32. package/src/interfaces/web/src/components/code/CodeSessionList.tsx +30 -26
  33. package/src/interfaces/web/src/components/code/CodeSidePanel.tsx +23 -19
  34. package/src/interfaces/web/src/components/code/CodeTerminal.tsx +140 -0
  35. package/src/interfaces/web/src/components/common/TabLayout.tsx +3 -3
  36. package/src/interfaces/web/src/components/ui/chat-input.tsx +17 -6
  37. package/src/interfaces/web/src/hooks/useChat.ts +1 -0
  38. package/src/interfaces/web/src/hooks/useNavCollapseCtx.tsx +25 -1
  39. package/src/interfaces/web/src/i18n/es.ts +1 -1
  40. package/src/interfaces/web/src/lib/api/agents.ts +1 -1
  41. package/src/interfaces/web/src/lib/api/artifacts.ts +10 -0
  42. package/src/interfaces/web/src/lib/api/code.ts +4 -2
  43. package/src/interfaces/web/src/screens/modules/CodeScreen.tsx +423 -79
  44. package/src/interfaces/web/src/screens/project/ChatTab.tsx +7 -10
  45. package/src/core/util/text-similarity.js +0 -52
  46. package/src/interfaces/web/dist/assets/index-34U_Mp1M.css +0 -1
  47. package/src/interfaces/web/dist/assets/index-BkybwwRn.js +0 -570
  48. package/src/interfaces/web/dist/assets/index-BkybwwRn.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentprojectcontext/apx",
3
- "version": "1.32.2",
3
+ "version": "1.33.1",
4
4
  "description": "APX — unified CLI + daemon for the Agent Project Context (APC) standard.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: apc-context
3
- description: "Activate whenever the project has a .apc/ directory or AGENTS.md — read .apc/ before assuming anything about agents, memory, or structure. If .apc/migrate.md exists, open with a migration offer first; if the user declines, delete it. Triggers: .apc/, AGENTS.md, 'which agents', 'list agents', 'agent context', 'who are the agents'."
3
+ description: "ALWAYS activate when the project has a .apc/ directory or AGENTS.md file. Do not wait to be asked. Read .apc/ before making any assumption about agents, memory, or project structure. Activate on: .apc/, AGENTS.md, 'which agents', 'list agents', 'agent context', 'who are the agents', any question about agents or memory in this project. IMPORTANT: if .apc/migrate.md exists, open the conversation with a migration offer before answering anything else. If the user declines, delete .apc/migrate.md immediately so it is not shown again."
4
4
  homepage: https://github.com/agentprojectcontext/agentprojectcontext
5
5
  ---
6
6
 
@@ -66,13 +66,11 @@ After migration:
66
66
  ```text
67
67
  AGENTS.md ← root project contract
68
68
  .apc/
69
- project.json ← project metadata (may carry an `apx` field)
70
- config.json ← project-only config overrides (e.g. super_agent.model)
69
+ project.json ← project metadata
71
70
  .gitignore ← safety guard
72
71
  agents/<name>.md ← agent definition
73
72
  agents/<name>/memory.md ← optional curated project memory
74
73
  skills/<name>.md ← reusable project instructions
75
- commands/ ← custom slash-commands (optional)
76
74
  mcps.json ← MCP hints without secrets
77
75
  ```
78
76
 
@@ -83,7 +81,6 @@ Do not store:
83
81
  .apc/sessions/
84
82
  .apc/conversations/
85
83
  .apc/messages/
86
- .apc/project.db
87
84
  .apc/cache/
88
85
  .apc/tmp/
89
86
  .apc/private/
@@ -5,11 +5,18 @@
5
5
  - If you cannot execute the action (missing permission, unclear params, tool not available), explain WHY — do not promise and disappear.
6
6
  - If the user asks you to do multiple things, do them all in the same turn using sequential tool calls if needed.
7
7
 
8
- ## One reply per turnno repeated greetings (mandatory)
9
- - A single turn can produce SEVERAL text segments: a short narration you write BEFORE calling a tool, and the final answer that comes AFTER the tool runs. On some surfaces each segment is shown separately.
10
- - Greet AT MOST ONCE per turn. If you already said "hola"/"hi" in an early segment, do NOT greet again in the final answer — start it with the actual content.
11
- - NEVER repeat the same sentence, greeting, or summary across segments of the same turn. Each segment is shown in full.
12
- - On simple requests, SKIP the intro entirely: go straight to the work, then give the result once. Only add a short intro when the work will clearly take more than a single quick tool call, and keep it to a few words ("un momento…", "reviso eso…").
8
+ ## Two-segment turns with toolsintro short, answer substantive (mandatory)
9
+ A turn that calls one or more tools produces TWO text segments shown to the user:
10
+
11
+ 1. **Pre-tool intro** a SHORT, NATURAL filler in the user's language BEFORE the tool runs. 2 to 8 words. NEVER contains the answer / data / acknowledgment. Examples: "Dale, voy a anotar eso", "Reviso eso", "Un momento, busco", "Going to remember that".
12
+ 2. **Post-tool answer** the SUBSTANTIVE result AFTER the tool returns. Carries the data, the confirmation, or the next question. Examples: "Listo, anoté que sos Tech Lead en Bytetravel.", "Encontré 3 routines activas: …".
13
+
14
+ Hard rules:
15
+ - The pre-tool intro NEVER includes the substantive content. Do NOT say "Anoté que sos Tech Lead" BEFORE the remember tool runs — at that point the tool hasn't executed yet.
16
+ - The post-tool answer NEVER restates what the intro already said. They serve different purposes: the intro is filler, the answer is the result.
17
+ - Greet AT MOST ONCE per turn. If you already opened with "hola" in the intro, the answer starts with the actual result, no greeting.
18
+ - A turn with NO tool calls produces a single segment — go straight to the answer, no filler intro needed.
19
+ - A simple chit-chat reply (no tool) is one segment: the reply itself.
13
20
 
14
21
  ## Chit-chat & greetings (only path out of a forced tool turn)
15
22
  - If the user is just greeting, chatting, or thanking you with NO actionable request ("hola", "hi", "buenas", "gracias", "👍", "ok"), you must STILL satisfy the tool-choice contract: call `finish` with a brief friendly reply in the user's language. Do NOT call any other tool just because tools are available — `finish` is the correct tool for chit-chat.
@@ -6,9 +6,13 @@ Formatting:
6
6
  - Keep replies brief (~6 sentences unless user asks for more)
7
7
  - Previous turns are conversational context only; re-call tools for facts
8
8
 
9
- What the user sees here: ONLY your final text reply. They do NOT see your tool calls, args, or intermediate results — those never reach Telegram. So if a request needs real work (running something, searching, editing, a multi-step task), the channel sends a short "on it" heads-up for you; you still must report what you actually did in plain words at the end. Never assume they saw what you ran.
9
+ What the user sees here: only your text segments. They do NOT see your tool calls, args, or intermediate results — those never reach Telegram.
10
10
 
11
- Segments policy: when you write any prose BEFORE calling a tool (an intro like "voy a revisar…") it lands as its OWN Telegram message — separate from the final answer that comes AFTER the tool runs. So:
12
- - Greet at most ONCE per turn. If you already said "Hola" in the intro segment, do NOT greet again in the final answer. Start the final answer with the actual content.
13
- - Prefer to skip the intro entirely on simple requests — go straight to the work, then answer. Only add an intro when the work will take noticeably longer than a single tool call.
14
- - Never repeat the same sentence across segmentseach message is shown in full to the user.
11
+ Two-segment turn (intro + answer):
12
+ - When you call a tool, write a SHORT natural intro BEFORE the tool runs (2–8 words in the user's language: "Dale, voy a anotar eso", "Reviso eso", "Un momento, busco esos datos"). That lands as a Telegram message of its own so the user sees you're working.
13
+ - AFTER the tool returns, write the substantive answer with the actual result or confirmation. That is the second Telegram message.
14
+ - The intro NEVER contains the substantive contentat that point the tool hasn't run yet, so you don't know the result. Wrong: "¡Anotado! Sos Tech Lead en Bytetravel" BEFORE remember runs. Right: "Dale, voy a anotar eso" before, then "Listo, anoté que sos Tech Lead." after.
15
+ - The answer NEVER restates the intro. They're complementary: filler + result, not the same content twice.
16
+ - Greet at most ONCE per turn. If the intro opened with "Hola", the answer starts with the result, no second greeting.
17
+
18
+ Turns without tools (small talk, "hola", "gracias"): a single message — the reply itself, no intro filler.
@@ -123,7 +123,7 @@ import { fileURLToPath } from "node:url";
123
123
  const __parserDir = path.dirname(fileURLToPath(import.meta.url));
124
124
 
125
125
  export const VAULT_DIR = path.join(os.homedir(), ".apx", "agents");
126
- export const BUNDLED_VAULT_DIR = path.resolve(__parserDir, "../../assets/agent-vault-defaults");
126
+ export const BUNDLED_VAULT_DIR = path.resolve(__parserDir, "../../../assets/agent-vault-defaults");
127
127
  export const VAULT_TOMBSTONE_PATH = path.join(VAULT_DIR, ".removed.json");
128
128
 
129
129
  function readVaultDirRaw(dir) {
@@ -16,7 +16,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
16
  // Now under src/core/apc/ — one more "../" to escape than before.
17
17
  const PACKAGE_ROOT = path.resolve(__dirname, "..", "..", "..");
18
18
  const BUNDLED_SKILLS_DIR = path.join(PACKAGE_ROOT, "skills");
19
- const RUNTIME_SKILLS_DIR = path.join(__dirname, "runtime-skills");
19
+ // runtime-skills lives at src/core/runtime-skills/, one level up from this
20
+ // file's new home in src/core/apc/ (was a sibling before the Phase 3 move).
21
+ const RUNTIME_SKILLS_DIR = path.join(__dirname, "..", "runtime-skills");
20
22
 
21
23
  export const SPEC_VERSION = "0.1.0";
22
24
 
@@ -4,7 +4,9 @@ import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
 
6
6
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
- export const PACKAGE_ROOT = path.resolve(__dirname, "..", "..");
7
+ // __dirname is src/core/apc/ after the Phase 3 move (was src/core/ before).
8
+ // Repo root is three levels up, not two.
9
+ export const PACKAGE_ROOT = path.resolve(__dirname, "..", "..", "..");
8
10
 
9
11
  export const APC_SKILL_REL = path.join("skills", "apc-context", "SKILL.md");
10
12
  export const APC_SKILL_REMOTE =
@@ -51,15 +51,24 @@ function toGeminiContents(messages) {
51
51
  if (m.role === "assistant" && Array.isArray(m.tool_calls) && m.tool_calls.length > 0) {
52
52
  out.push({
53
53
  role: "model",
54
- parts: m.tool_calls.map((tc) => ({
55
- functionCall: {
56
- name: tc.function?.name || tc.name,
57
- args:
58
- typeof tc.function?.arguments === "string"
59
- ? safeParseJson(tc.function.arguments)
60
- : tc.function?.arguments || tc.arguments || {},
61
- },
62
- })),
54
+ parts: m.tool_calls.map((tc) => {
55
+ const part = {
56
+ functionCall: {
57
+ name: tc.function?.name || tc.name,
58
+ args:
59
+ typeof tc.function?.arguments === "string"
60
+ ? safeParseJson(tc.function.arguments)
61
+ : tc.function?.arguments || tc.arguments || {},
62
+ },
63
+ };
64
+ // Gemini 3.x thinking models require us to echo back the
65
+ // thoughtSignature that came attached to the original functionCall
66
+ // part, or the API rejects the next turn with 400. We captured it
67
+ // in the response parser; replay it verbatim when present.
68
+ const sig = tc._thoughtSignature || tc.thought_signature;
69
+ if (sig) part.thoughtSignature = sig;
70
+ return part;
71
+ }),
63
72
  });
64
73
  continue;
65
74
  }
@@ -143,14 +152,22 @@ export default {
143
152
  for (const p of parts) {
144
153
  const fc = p.functionCall || p.function_call;
145
154
  if (fc?.name) {
146
- toolCalls.push({
155
+ const tc = {
147
156
  id: `gemini_${randomUUID().slice(0, 8)}`,
148
157
  type: "function",
149
158
  function: {
150
159
  name: fc.name,
151
160
  arguments: typeof fc.args === "string" ? fc.args : JSON.stringify(fc.args || {}),
152
161
  },
153
- });
162
+ };
163
+ // Thinking models (Gemini 3.x) attach a thoughtSignature to the part
164
+ // alongside the functionCall. We must replay it on the next request
165
+ // or the API 400s. Carry it on the tool_call so the next call to
166
+ // toGeminiContents() can put it back. Underscore prefix marks it as
167
+ // adapter-private metadata other engines should ignore.
168
+ const sig = p.thoughtSignature || p.thought_signature;
169
+ if (sig) tc._thoughtSignature = sig;
170
+ toolCalls.push(tc);
154
171
  }
155
172
  }
156
173
 
@@ -52,12 +52,22 @@ export async function callEngine({ modelId, system, messages, config, temperatur
52
52
  const { provider, model } = resolveProvider(modelId);
53
53
  const adapter = getAdapter(provider);
54
54
  const providerCfg = (config && config.engines && config.engines[provider]) || {};
55
+ // The per-provider `default_max_tokens` set in the web admin (Provider modal
56
+ // slider) acts as a floor: callers may ask for more, but never less. This
57
+ // matters for "thinking" models (e.g. Gemini 3.x) whose internal reasoning
58
+ // tokens count against maxOutputTokens — too low a cap and the visible reply
59
+ // gets truncated mid-sentence. Fallback chain:
60
+ // caller value → provider cfg → 2048 (safe baseline that survives thinking
61
+ // models without truncating; non-thinking models just don't fill it).
62
+ const providerCap = Number(providerCfg.default_max_tokens) || 0;
63
+ const callerCap = Number(maxTokens) || 0;
64
+ const effectiveMaxTokens = Math.max(callerCap, providerCap) || 2048;
55
65
  return adapter.chat({
56
66
  system,
57
67
  messages,
58
68
  model,
59
69
  temperature,
60
- maxTokens,
70
+ maxTokens: effectiveMaxTokens,
61
71
  tools,
62
72
  toolChoice,
63
73
  config: providerCfg,
@@ -49,6 +49,7 @@ function toRow(s) {
49
49
  title: s.title,
50
50
  mode: s.mode,
51
51
  model: s.model || null,
52
+ agentSlug: s.agentSlug || null,
52
53
  createdAt: s.createdAt,
53
54
  updatedAt: s.updatedAt,
54
55
  messageCount: Array.isArray(s.messages) ? s.messages.length : 0,
@@ -78,7 +79,7 @@ export function getCodeSession(storagePath, id) {
78
79
 
79
80
  /**
80
81
  * Create a new session.
81
- * fields: { projectId, title?, model?, mode?, git? }
82
+ * fields: { projectId, title?, model?, mode?, git?, agentSlug? }
82
83
  */
83
84
  export function createCodeSession(storagePath, fields = {}) {
84
85
  const id = shortId();
@@ -91,6 +92,7 @@ export function createCodeSession(storagePath, fields = {}) {
91
92
  updatedAt: ts,
92
93
  model: fields.model || null,
93
94
  mode: fields.mode === "plan" ? "plan" : "build",
95
+ agentSlug: fields.agentSlug || null,
94
96
  git: fields.git && typeof fields.git === "object" ? fields.git : null,
95
97
  messages: [],
96
98
  };
@@ -108,6 +110,7 @@ export function updateCodeSession(storagePath, id, patch = {}) {
108
110
  if (patch.title != null) session.title = String(patch.title).trim() || session.title;
109
111
  if (patch.model !== undefined) session.model = patch.model || null;
110
112
  if (patch.mode === "plan" || patch.mode === "build") session.mode = patch.mode;
113
+ if (patch.agentSlug !== undefined) session.agentSlug = patch.agentSlug || null;
111
114
  if (patch.git !== undefined) session.git = patch.git;
112
115
  session.updatedAt = nowIso();
113
116
  writeJson(sessionFile(storagePath, id), session);
@@ -119,6 +119,31 @@ export function register(app, { project }) {
119
119
  }
120
120
  });
121
121
 
122
+ app.patch("/projects/:pid/artifacts/:name", (req, res) => {
123
+ const p = project(req, res);
124
+ if (!p) return;
125
+ const name = decodeURIComponent(req.params.name);
126
+ const { content, newName } = req.body || {};
127
+ try {
128
+ const absPath = artifactPath(p.storagePath, name);
129
+ if (!fs.existsSync(absPath)) {
130
+ return res.status(404).json({ error: `artifact "${name}" not found` });
131
+ }
132
+ if (typeof content === "string") {
133
+ fs.writeFileSync(absPath, content, "utf8");
134
+ }
135
+ let finalName = name;
136
+ if (newName && newName !== name) {
137
+ const newAbsPath = artifactPath(p.storagePath, newName);
138
+ fs.renameSync(absPath, newAbsPath);
139
+ finalName = newName;
140
+ }
141
+ res.json({ ok: true, name: finalName });
142
+ } catch (e) {
143
+ res.status(400).json({ error: e.message });
144
+ }
145
+ });
146
+
122
147
  app.delete("/projects/:pid/artifacts/:name", (req, res) => {
123
148
  const p = project(req, res);
124
149
  if (!p) return;
@@ -26,6 +26,7 @@ import {
26
26
  } from "#core/stores/code-sessions.js";
27
27
  import { captureBaseline, diffAgainstBaseline, initGitRepo } from "#core/git-baseline.js";
28
28
  import { loggerFor } from "#core/logging.js";
29
+ import { readAgents } from "#core/apc/parser.js";
29
30
 
30
31
  const log = loggerFor("code");
31
32
 
@@ -212,7 +213,7 @@ export function register(app, { projects, project, config, registries, plugins }
212
213
  app.post("/projects/:pid/code/sessions", (req, res) => {
213
214
  const p = findProject(req, res);
214
215
  if (!p) return;
215
- const { title, model, mode } = req.body || {};
216
+ const { title, model, mode, agentSlug } = req.body || {};
216
217
  let git = captureBaseline(p.path);
217
218
  // No baseline because the project isn't a git repo yet. For real projects
218
219
  // (not the default apx home, id 0) init one so the "changes" diff works —
@@ -230,6 +231,7 @@ export function register(app, { projects, project, config, registries, plugins }
230
231
  title,
231
232
  model,
232
233
  mode,
234
+ agentSlug: agentSlug || null,
233
235
  git,
234
236
  });
235
237
  res.status(201).json(session);
@@ -291,6 +293,15 @@ export function register(app, { projects, project, config, registries, plugins }
291
293
  const mode = session.mode === "plan" ? "plan" : "build";
292
294
  const previousMessages = historyFrom(session);
293
295
 
296
+ // If a project agent is selected, inject its system prompt as a suffix so
297
+ // the super-agent's tool loop runs with the agent's personality/context.
298
+ let agentSystemSuffix = "";
299
+ if (session.agentSlug) {
300
+ const agents = readAgents(p.path);
301
+ const agent = agents.find((a) => a.slug === session.agentSlug);
302
+ if (agent?.body) agentSystemSuffix = `\n\n## Agente seleccionado: ${session.agentSlug}\n${agent.body}`;
303
+ }
304
+
294
305
  // Persist the user turn immediately so a crash mid-stream still records it.
295
306
  appendTurn(p.storagePath, session.id, {
296
307
  role: "user",
@@ -324,8 +335,10 @@ export function register(app, { projects, project, config, registries, plugins }
324
335
  projectPath: p.path,
325
336
  mode,
326
337
  modeGuidance: modeGuidanceFor(mode),
338
+ agentSlug: session.agentSlug || null,
327
339
  },
328
340
  previousMessages,
341
+ systemSuffix: agentSystemSuffix,
329
342
  overrideModel: session.model || undefined,
330
343
  allowedTools: mode === "plan" ? PLAN_TOOLS : "*",
331
344
  // Coding tasks are multi-step: give the loop a high safety ceiling so it
@@ -13,6 +13,11 @@ const DEFAULT_BASE = {
13
13
  ollama: "http://localhost:11434",
14
14
  };
15
15
 
16
+ // Gemini's native models endpoint returns a much richer catalog than the
17
+ // OpenAI-compat shim (which only echoes back a handful). We always query the
18
+ // native URL regardless of the user's configured base_url.
19
+ const GEMINI_NATIVE_BASE = "https://generativelanguage.googleapis.com/v1beta";
20
+
16
21
  // Returns { models } or { error }. Reads the right /models endpoint per engine.
17
22
  async function listModels(engine, baseUrl, apiKey) {
18
23
  const base = String(baseUrl || DEFAULT_BASE[engine] || "").replace(/\/$/, "");
@@ -37,7 +42,32 @@ async function listModels(engine, baseUrl, apiKey) {
37
42
  return { models: data.map((m) => m?.id).filter(Boolean) };
38
43
  }
39
44
 
40
- // openai-compatible family: openai, groq, openrouter, gemini, azure, custom
45
+ if (engine === "gemini") {
46
+ if (!apiKey) return { error: "falta api_key" };
47
+ // Native Gemini API: returns a `models` array with rich metadata, including
48
+ // `supportedGenerationMethods` so we can drop embeddings/vision-only entries.
49
+ // Names come back as "models/<id>"; strip the prefix for display.
50
+ const r = await fetchJsonWithTimeout(
51
+ `${GEMINI_NATIVE_BASE}/models?key=${encodeURIComponent(apiKey)}&pageSize=200`,
52
+ { timeoutMs: 5000 },
53
+ );
54
+ if (!r.ok) return { error: r.reason || `HTTP ${r.status}` };
55
+ const data = Array.isArray(r.json?.models) ? r.json.models : [];
56
+ const models = data
57
+ .filter((m) => {
58
+ const methods = m?.supportedGenerationMethods;
59
+ if (!Array.isArray(methods)) return true;
60
+ return methods.includes("generateContent");
61
+ })
62
+ .map((m) => {
63
+ const name = typeof m?.name === "string" ? m.name : "";
64
+ return name.startsWith("models/") ? name.slice("models/".length) : name;
65
+ })
66
+ .filter(Boolean);
67
+ return { models };
68
+ }
69
+
70
+ // openai-compatible family: openai, groq, openrouter, azure, custom
41
71
  if (!apiKey) return { error: "falta api_key" };
42
72
  if (!base) return { error: "falta base_url" };
43
73
  const r = await fetchJsonWithTimeout(`${base}/models`, {
@@ -7,6 +7,7 @@
7
7
  import { callEngine } from "#core/engines/index.js";
8
8
  import { readAgents } from "#core/apc/parser.js";
9
9
  import { buildAgentSystem } from "#core/agent/build-agent-system.js";
10
+ import { resolveActiveModel } from "#core/agent/model-router.js";
10
11
  import {
11
12
  startConversation,
12
13
  appendTurn,
@@ -14,6 +15,20 @@ import {
14
15
  setStatus,
15
16
  } from "../conversations.js";
16
17
 
18
+ // Pick a model for a direct agent chat: explicit override → agent's own model →
19
+ // super-agent default (resolved via the same router the super-agent uses, so
20
+ // it walks the fallback chain when the primary is empty/unhealthy).
21
+ async function pickAgentModel({ modelOverride, agent, config }) {
22
+ if (modelOverride) return modelOverride;
23
+ if (agent.fields?.Model) return agent.fields.Model;
24
+ try {
25
+ const routing = await resolveActiveModel(config);
26
+ return routing?.modelId || null;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
17
32
  export function register(app, { projects, project, config }) {
18
33
  app.post("/projects/:pid/agents/:slug/exec", async (req, res) => {
19
34
  const p = project(req, res);
@@ -28,7 +43,7 @@ export function register(app, { projects, project, config }) {
28
43
  const agents = readAgents(p.path);
29
44
  const agent = agents.find((a) => a.slug === req.params.slug);
30
45
  if (!agent) return res.status(404).json({ error: "agent not found" });
31
- const modelId = modelOverride || agent.fields.Model;
46
+ const modelId = await pickAgentModel({ modelOverride, agent, config });
32
47
  if (!modelId)
33
48
  return res
34
49
  .status(400)
@@ -106,7 +121,7 @@ export function register(app, { projects, project, config }) {
106
121
  const agents = readAgents(p.path);
107
122
  const agent = agents.find((a) => a.slug === req.params.slug);
108
123
  if (!agent) return res.status(404).json({ error: "agent not found" });
109
- const modelId = modelOverride || agent.fields.Model;
124
+ const modelId = await pickAgentModel({ modelOverride, agent, config });
110
125
  if (!modelId)
111
126
  return res
112
127
  .status(400)