@agentprojectcontext/apx 1.10.4 → 1.11.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentprojectcontext/apx",
3
- "version": "1.10.4",
3
+ "version": "1.11.0",
4
4
  "description": "APX — unified CLI + daemon for the Agent Project Context (APC) standard.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -35,6 +35,10 @@
35
35
  "express": "^4.21.0",
36
36
  "node-fetch": "^3.3.2"
37
37
  },
38
+ "optionalDependencies": {
39
+ "fast-glob": "^3.3.2",
40
+ "puppeteer": "^22.0.0"
41
+ },
38
42
  "devDependencies": {
39
43
  "@semantic-release/changelog": "^6.0.3",
40
44
  "@semantic-release/git": "^10.0.1",
@@ -0,0 +1,62 @@
1
+ // cli/commands/search.js
2
+ // `apx search "query"` — web search from the terminal.
3
+ // Uses the daemon's tools/search.js module directly (no HTTP roundtrip,
4
+ // no need to have `apx daemon start` running).
5
+
6
+ import { webSearch } from "../../daemon/tools/search.js";
7
+
8
+ const DIM = "\x1b[2m";
9
+ const BOLD = "\x1b[1m";
10
+ const BLUE = "\x1b[34m";
11
+ const CYAN = "\x1b[36m";
12
+ const RESET = "\x1b[0m";
13
+
14
+ export async function cmdSearch(args) {
15
+ const query = (args._ || []).join(" ").trim();
16
+ if (!query) {
17
+ console.error("apx search: missing <query>");
18
+ console.error("usage: apx search \"<query>\" [--mode auto|ddg|brave|browser] [-n N]");
19
+ process.exit(1);
20
+ }
21
+
22
+ const mode = args.flags?.mode || "auto";
23
+ const limit = parseInt(args.flags?.n || args.flags?.limit || "5", 10) || 5;
24
+ const json = !!args.flags?.json;
25
+
26
+ process.stderr.write(`${DIM}Searching... (${mode})${RESET}\n`);
27
+
28
+ let r;
29
+ try {
30
+ r = await webSearch({ query, mode, limit });
31
+ } catch (e) {
32
+ console.error(`${DIM}error:${RESET} ${e.message}`);
33
+ process.exit(1);
34
+ }
35
+
36
+ if (json) {
37
+ console.log(JSON.stringify(r, null, 2));
38
+ return;
39
+ }
40
+
41
+ const results = r.results || [];
42
+ if (results.length === 0) {
43
+ console.log(`${DIM}(no results from ${r.mode})${RESET}`);
44
+ if (r.raw_excerpt) console.log(`${DIM}excerpt:${RESET} ${r.raw_excerpt.slice(0, 400)}…`);
45
+ return;
46
+ }
47
+
48
+ console.log(`${DIM}${results.length} result${results.length === 1 ? "" : "s"} via ${r.mode}${RESET}\n`);
49
+ results.forEach((item, i) => {
50
+ const num = `[${i + 1}]`;
51
+ const title = item.title || "(no title)";
52
+ console.log(`${BOLD}${num} ${title}${RESET} ${DIM}— ${hostname(item.url)}${RESET}`);
53
+ if (item.snippet) console.log(` ${item.snippet}`);
54
+ console.log(` ${BLUE}${item.url}${RESET}`);
55
+ if (i < results.length - 1) console.log("");
56
+ });
57
+ }
58
+
59
+ function hostname(url) {
60
+ try { return new URL(url).hostname.replace(/^www\./, ""); }
61
+ catch { return url; }
62
+ }
package/src/cli/index.js CHANGED
@@ -54,6 +54,7 @@ import {
54
54
  cmdTelegramSetup,
55
55
  } from "./commands/telegram.js";
56
56
  import { cmdMessagesTail, cmdMessagesSearch, cmdMessagesChat } from "./commands/messages.js";
57
+ import { cmdSearch } from "./commands/search.js";
57
58
  import { cmdExec } from "./commands/exec.js";
58
59
  import {
59
60
  cmdChat,
@@ -653,6 +654,21 @@ const HELP_TOPICS = new Map(Object.entries({
653
654
  options: [["--project <name|id|path>", "Pin command to a specific project."]],
654
655
  examples: ["apx messages search \"deploy\""],
655
656
  }),
657
+ search: topic({
658
+ title: "apx search",
659
+ summary: "Web search from the terminal (DuckDuckGo / Brave / Puppeteer fallback).",
660
+ usage: ["apx search \"<query>\" [--mode auto|ddg|brave|browser] [-n N] [--json]"],
661
+ options: [
662
+ ["--mode <m>", "auto (default) | ddg | brave | browser. Brave needs BRAVE_API_KEY."],
663
+ ["-n N", "Number of results (default 5, max 20)."],
664
+ ["--json", "Output raw JSON instead of the formatted list."],
665
+ ],
666
+ examples: [
667
+ "apx search \"agent project context\"",
668
+ "apx search \"node 22 release notes\" --mode ddg -n 10",
669
+ "apx search \"weather buenos aires\" --json",
670
+ ],
671
+ }),
656
672
  "messages chat": topic({
657
673
  title: "apx messages chat",
658
674
  summary: "Print APX messages as a chat transcript with user, agent, tool, or system type.",
@@ -1129,6 +1145,7 @@ function buildHelp(version) {
1129
1145
  hCmd("apx code", 36, "APX terminal coding assistant"),
1130
1146
  hCmd("apx exec <agent> \"prompt\"",36, "one-shot agent call --model <id> --max-tokens N"),
1131
1147
  hCmd("apx chat <agent>", 36, "interactive agent REPL --conversation <id>"),
1148
+ hCmd("apx search \"query\"", 36, "web search (ddg | brave | browser) --mode <m> -n N"),
1132
1149
  hCmd("apx conversations list", 36, "stored exec/chat conversations for <agent>"),
1133
1150
  hCmd("apx conversations get", 36, "<agent> <id>"),
1134
1151
 
@@ -1433,6 +1450,10 @@ async function dispatch(cmd, rest) {
1433
1450
  await cmdExec(parseArgs(rest));
1434
1451
  break;
1435
1452
 
1453
+ case "search":
1454
+ await cmdSearch(parseArgs(rest));
1455
+ break;
1456
+
1436
1457
  case "chat":
1437
1458
  await cmdChat(parseArgs(rest));
1438
1459
  break;
@@ -1,6 +1,17 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
 
4
+ // ---------------------------------------------------------------------------
5
+ // Anti-ghost-response rules injected into every agent system prompt.
6
+ // Prevents agents from saying "Ok, I'll do that" and then doing nothing.
7
+ // ---------------------------------------------------------------------------
8
+ const ACTION_DISCIPLINE_RULES = `## Action Discipline (mandatory)
9
+ - NEVER acknowledge an action without executing it in the same turn. If you are going to do something, call the tool FIRST, then report the result.
10
+ - NEVER use empty acknowledgments like "Ok", "Got it", "Sure", "Understood", "On it", "Give me a moment", "I'll do that now" as standalone responses when a tool call is expected. These are invalid responses.
11
+ - Action first, report after. Produce the tool call in the same response as your acknowledgment.
12
+ - If you cannot execute the action (missing permission, unclear params, tool not available), explain WHY — do not promise and disappear.
13
+ - If the user asks you to do multiple things, do them all in the same turn using sequential tool calls if needed.`;
14
+
4
15
  function listField(value) {
5
16
  if (Array.isArray(value)) return value.map(String).map((s) => s.trim()).filter(Boolean);
6
17
  return String(value || "").split(",").map((s) => s.trim()).filter(Boolean);
@@ -66,6 +77,10 @@ export function buildAgentSystem(project, agent, {
66
77
  if (ep) parts.push(ep);
67
78
  }
68
79
 
80
+ // Always append action discipline rules last so they are close to the end
81
+ // of the system prompt and harder for the model to "forget".
82
+ parts.push(ACTION_DISCIPLINE_RULES);
83
+
69
84
  return parts.join("\n\n");
70
85
  }
71
86
 
package/src/daemon/api.js CHANGED
@@ -3,6 +3,12 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { execFile } from "node:child_process";
5
5
  import express from "express";
6
+ import { buildBrowserRouter } from "./tools/browser.js";
7
+ import { buildFetchRouter } from "./tools/fetch.js";
8
+ import { buildSearchRouter } from "./tools/search.js";
9
+ import { buildRegistryRouter } from "./tools/registry.js";
10
+ import { buildGlobRouter } from "./tools/glob.js";
11
+ import { buildGrepRouter } from "./tools/grep.js";
6
12
  import { readApfMcps, writeApfMcps, SOURCES } from "./mcp-sources.js";
7
13
  import { callEngine, ENGINE_IDS } from "./engines/index.js";
8
14
  import { getRuntime, RUNTIME_IDS } from "./runtimes/index.js";
@@ -58,6 +64,18 @@ export function buildApi({ projects, registries, plugins, scheduler, version, st
58
64
  const app = express();
59
65
  app.use(express.json({ limit: "2mb" }));
60
66
 
67
+ // ---- Tool routers (fetch / browser / search / glob / grep / registry) ----
68
+ // fetch = native HTTP, no Chromium → fast, cheap, default for REST/HTML
69
+ // browser = Puppeteer-backed → heavy, lazy-launched, for JS-rendered pages
70
+ app.use("/tools/fetch", buildFetchRouter(express));
71
+ app.use("/tools/browser", buildBrowserRouter(express));
72
+ app.use("/tools/search", buildSearchRouter(express));
73
+ app.use("/tools/glob", buildGlobRouter(express));
74
+ app.use("/tools/grep", buildGrepRouter(express));
75
+ // Registry MUST be mounted after specific routers so /:name wildcard
76
+ // doesn't shadow /tools/browser, /tools/fetch, /tools/search, etc.
77
+ app.use("/tools", buildRegistryRouter(express, { projects, registries }));
78
+
61
79
  // ---- Health -------------------------------------------------------
62
80
  app.get("/health", (_req, res) => {
63
81
  res.json({
@@ -399,6 +417,60 @@ export function buildApi({ projects, registries, plugins, scheduler, version, st
399
417
  }
400
418
  });
401
419
 
420
+ // POST /telegram/send_photo { chat_id?, photo (path|url), caption?, channel? }
421
+ app.post("/telegram/send_photo", async (req, res) => {
422
+ const { chat_id, photo, caption, parse_mode, channel } = req.body || {};
423
+ if (!photo) return res.status(400).json({ error: "photo required (path or url)" });
424
+ if (!telegram) return res.status(503).json({ error: "telegram plugin not loaded" });
425
+ try {
426
+ const r = await telegram.sendPhoto({ chat_id, photo, caption, parse_mode, channel });
427
+ res.status(202).json({ ok: true, message_id: r?.message_id });
428
+ } catch (e) {
429
+ res.status(502).json({ error: e.message });
430
+ }
431
+ });
432
+
433
+ // POST /telegram/send_voice { chat_id?, audio (path), caption?, duration?, channel? }
434
+ app.post("/telegram/send_voice", async (req, res) => {
435
+ const { chat_id, audio, caption, duration, channel } = req.body || {};
436
+ if (!audio) return res.status(400).json({ error: "audio required (path)" });
437
+ if (!telegram) return res.status(503).json({ error: "telegram plugin not loaded" });
438
+ try {
439
+ const r = await telegram.sendVoice({ chat_id, audio, caption, duration, channel });
440
+ res.status(202).json({ ok: true, message_id: r?.message_id });
441
+ } catch (e) {
442
+ res.status(502).json({ error: e.message });
443
+ }
444
+ });
445
+
446
+ // POST /telegram/send_audio { chat_id?, audio (path), caption?, title?, performer?, channel? }
447
+ app.post("/telegram/send_audio", async (req, res) => {
448
+ const { chat_id, audio, caption, title, performer, channel } = req.body || {};
449
+ if (!audio) return res.status(400).json({ error: "audio required (path)" });
450
+ if (!telegram) return res.status(503).json({ error: "telegram plugin not loaded" });
451
+ try {
452
+ const r = await telegram.sendAudio({ chat_id, audio, caption, title, performer, channel });
453
+ res.status(202).json({ ok: true, message_id: r?.message_id });
454
+ } catch (e) {
455
+ res.status(502).json({ error: e.message });
456
+ }
457
+ });
458
+
459
+ // POST /telegram/notify — alias for /telegram/send for proactive daemon notifications
460
+ // Any internal daemon code (routines, error handlers, MCP failure hooks) can POST here
461
+ // to push a message to the user without waiting for a user-initiated request.
462
+ app.post("/telegram/notify", async (req, res) => {
463
+ const { chat_id, text, channel } = req.body || {};
464
+ if (!text) return res.status(400).json({ error: "text required" });
465
+ if (!telegram) return res.status(503).json({ error: "telegram plugin not loaded" });
466
+ try {
467
+ const r = await telegram.send({ chat_id, text, channel });
468
+ res.status(202).json({ ok: true, message_id: r.message_id, via: "notify" });
469
+ } catch (e) {
470
+ res.status(502).json({ error: e.message });
471
+ }
472
+ });
473
+
402
474
  // ---- Plugins -----------------------------------------------------
403
475
  app.get("/plugins", (_req, res) => {
404
476
  if (!plugins) return res.json({});
@@ -1174,6 +1246,163 @@ export function buildApi({ projects, registries, plugins, scheduler, version, st
1174
1246
  }
1175
1247
  });
1176
1248
 
1249
+ // ---- Session search (cross-agent, cross-conversation) ------------
1250
+ // GET /sessions/search?q=...&project=...&limit=20
1251
+ // Searches session files (.apc/agents/{slug}/sessions/*.md) and
1252
+ // conversation files (~/.apx/.../conversations/*.md) by text content.
1253
+ app.get("/sessions/search", (req, res) => {
1254
+ const { q, project: projectRef, limit = "20" } = req.query;
1255
+ if (!q) return res.status(400).json({ error: "q required" });
1256
+ const lim = Math.min(parseInt(limit, 10) || 20, 200);
1257
+ const needle = q.toLowerCase();
1258
+
1259
+ // Resolve project (or search all)
1260
+ const allProjects = projects.list();
1261
+ const targetProjects = (() => {
1262
+ if (projectRef != null) {
1263
+ const ref = String(projectRef);
1264
+ const found = allProjects.find((p) => String(p.id) === ref || p.path === path.resolve(ref));
1265
+ return found ? [projects.get(found.id)] : [];
1266
+ }
1267
+ return allProjects.map((p) => projects.get(p.id)).filter(Boolean);
1268
+ })();
1269
+
1270
+ const matches = [];
1271
+
1272
+ for (const p of targetProjects) {
1273
+ if (!p) continue;
1274
+
1275
+ // 1. Search session files in project (.apc/agents/{slug}/sessions/)
1276
+ const sessionAgentsDir = path.join(p.path, ".apc", "agents");
1277
+ if (fs.existsSync(sessionAgentsDir)) {
1278
+ for (const slug of fs.readdirSync(sessionAgentsDir)) {
1279
+ const sessionsDir = path.join(sessionAgentsDir, slug, "sessions");
1280
+ if (!fs.existsSync(sessionsDir)) continue;
1281
+ for (const f of fs.readdirSync(sessionsDir).filter((x) => x.endsWith(".md"))) {
1282
+ const filePath = path.join(sessionsDir, f);
1283
+ try {
1284
+ const text = fs.readFileSync(filePath, "utf8");
1285
+ if (text.toLowerCase().includes(needle)) {
1286
+ // Find matching excerpt
1287
+ const lines = text.split("\n");
1288
+ const matchLine = lines.findIndex((l) => l.toLowerCase().includes(needle));
1289
+ const excerpt = lines.slice(Math.max(0, matchLine - 1), matchLine + 3).join("\n");
1290
+ matches.push({
1291
+ type: "session",
1292
+ project: p.id,
1293
+ agent: slug,
1294
+ filename: f,
1295
+ path: filePath,
1296
+ excerpt: excerpt.slice(0, 300),
1297
+ });
1298
+ if (matches.length >= lim) break;
1299
+ }
1300
+ } catch {}
1301
+ }
1302
+ if (matches.length >= lim) break;
1303
+ }
1304
+ }
1305
+
1306
+ if (matches.length >= lim) break;
1307
+
1308
+ // 2. Search conversation files in daemon storage (~/.apx/.../conversations/)
1309
+ const convAgentsDir = path.join(p.storagePath, "agents");
1310
+ if (fs.existsSync(convAgentsDir)) {
1311
+ for (const slug of fs.readdirSync(convAgentsDir)) {
1312
+ const convDir = path.join(convAgentsDir, slug, "conversations");
1313
+ if (!fs.existsSync(convDir)) continue;
1314
+ for (const f of fs.readdirSync(convDir).filter((x) => x.endsWith(".md"))) {
1315
+ const filePath = path.join(convDir, f);
1316
+ try {
1317
+ const text = fs.readFileSync(filePath, "utf8");
1318
+ if (text.toLowerCase().includes(needle)) {
1319
+ const lines = text.split("\n");
1320
+ const matchLine = lines.findIndex((l) => l.toLowerCase().includes(needle));
1321
+ const excerpt = lines.slice(Math.max(0, matchLine - 1), matchLine + 3).join("\n");
1322
+ matches.push({
1323
+ type: "conversation",
1324
+ project: p.id,
1325
+ agent: slug,
1326
+ filename: f,
1327
+ path: filePath,
1328
+ excerpt: excerpt.slice(0, 300),
1329
+ });
1330
+ if (matches.length >= lim) break;
1331
+ }
1332
+ } catch {}
1333
+ }
1334
+ if (matches.length >= lim) break;
1335
+ }
1336
+ }
1337
+
1338
+ if (matches.length >= lim) break;
1339
+ }
1340
+
1341
+ res.json({ q, count: matches.length, results: matches });
1342
+ });
1343
+
1344
+ // POST /sessions/:id/compact
1345
+ // Shortcut: resolves which project/agent owns the session file,
1346
+ // then delegates to the existing compactConversation logic.
1347
+ // Body: { project?, model? }
1348
+ app.post("/sessions/:id/compact", async (req, res) => {
1349
+ const { id } = req.params;
1350
+ const { model: modelOverride, project: projectRef } = req.body || {};
1351
+
1352
+ // Find which project/agent owns this session ID
1353
+ const allProjects = projectRef != null
1354
+ ? (() => {
1355
+ const ref = String(projectRef);
1356
+ const found = projects.list().find((p) => String(p.id) === ref || p.path === path.resolve(ref));
1357
+ return found ? [projects.get(found.id)] : [];
1358
+ })()
1359
+ : projects.list().map((p) => projects.get(p.id)).filter(Boolean);
1360
+
1361
+ let found = null;
1362
+ const filename = id.endsWith(".md") ? id : `${id}.md`;
1363
+
1364
+ for (const p of allProjects) {
1365
+ if (!p) continue;
1366
+ // Search in daemon conversation storage
1367
+ const agentsDir = path.join(p.storagePath, "agents");
1368
+ if (fs.existsSync(agentsDir)) {
1369
+ for (const slug of fs.readdirSync(agentsDir)) {
1370
+ const f = path.join(agentsDir, slug, "conversations", filename);
1371
+ if (fs.existsSync(f)) {
1372
+ found = { p, slug };
1373
+ break;
1374
+ }
1375
+ }
1376
+ }
1377
+ if (found) break;
1378
+ }
1379
+
1380
+ if (!found) {
1381
+ return res.status(404).json({ error: `session/conversation "${id}" not found` });
1382
+ }
1383
+
1384
+ const { p, slug } = found;
1385
+ const { readAgents: _readAgents } = await import("../core/parser.js");
1386
+ const agents = _readAgents(p.path);
1387
+ const agent = agents.find((a) => a.slug === slug);
1388
+ const modelId = modelOverride || agent?.fields?.Model;
1389
+ if (!modelId) return res.status(400).json({ error: "agent has no model; pass model in body" });
1390
+
1391
+ try {
1392
+ const { compactConversation } = await import("./compact.js");
1393
+ const result = await compactConversation({
1394
+ storagePath: p.storagePath,
1395
+ agentSlug: slug,
1396
+ filename,
1397
+ modelId,
1398
+ config: p.config || config,
1399
+ });
1400
+ res.json(result);
1401
+ } catch (e) {
1402
+ res.status(500).json({ error: e.message });
1403
+ }
1404
+ });
1405
+
1177
1406
  // ---- Admin --------------------------------------------------------
1178
1407
  app.post("/admin/shutdown", (_req, res) => {
1179
1408
  res.json({ ok: true });
@@ -11,7 +11,7 @@ function getKey(config) {
11
11
  export default {
12
12
  id: "anthropic",
13
13
 
14
- async chat({ system, messages, model, temperature = 1.0, maxTokens = 1024, config = {} }) {
14
+ async chat({ system, messages, model, temperature = 1.0, maxTokens = 1024, config = {}, tools, toolChoice }) {
15
15
  const key = getKey(config);
16
16
  if (!key) throw new Error("anthropic: no api_key (set ANTHROPIC_API_KEY or engines.anthropic.api_key)");
17
17
  if (!model) throw new Error("anthropic: model required");
@@ -27,6 +27,18 @@ export default {
27
27
  };
28
28
  if (system) body.system = system;
29
29
 
30
+ // Tool use support — pass tools array and optional tool_choice.
31
+ // toolChoice="required" → { type: "any" } forces at least one tool call per turn,
32
+ // preventing the model from giving an empty acknowledgment instead of acting.
33
+ if (tools && tools.length > 0) {
34
+ body.tools = tools;
35
+ if (toolChoice === "required" || toolChoice === "any") {
36
+ body.tool_choice = { type: "any" };
37
+ } else if (toolChoice && typeof toolChoice === "object") {
38
+ body.tool_choice = toolChoice;
39
+ }
40
+ }
41
+
30
42
  const res = await fetch(API_BASE, {
31
43
  method: "POST",
32
44
  headers: {
@@ -42,12 +54,18 @@ export default {
42
54
  `anthropic ${res.status}: ${json?.error?.message || JSON.stringify(json)}`
43
55
  );
44
56
  }
57
+
58
+ // Extract text blocks; also capture tool_use blocks for callers that need them
45
59
  const text = (json.content || [])
46
60
  .filter((b) => b.type === "text")
47
61
  .map((b) => b.text)
48
62
  .join("");
63
+ const toolUses = (json.content || []).filter((b) => b.type === "tool_use");
64
+
49
65
  return {
50
66
  text,
67
+ tool_uses: toolUses.length > 0 ? toolUses : undefined,
68
+ stop_reason: json.stop_reason,
51
69
  usage: {
52
70
  input_tokens: json.usage?.input_tokens || 0,
53
71
  output_tokens: json.usage?.output_tokens || 0,
@@ -46,7 +46,7 @@ export function getAdapter(provider) {
46
46
  return a;
47
47
  }
48
48
 
49
- export async function callEngine({ modelId, system, messages, config, temperature, maxTokens, tools }) {
49
+ export async function callEngine({ modelId, system, messages, config, temperature, maxTokens, tools, toolChoice }) {
50
50
  const { provider, model } = resolveProvider(modelId);
51
51
  const adapter = getAdapter(provider);
52
52
  const providerCfg =
@@ -58,6 +58,7 @@ export async function callEngine({ modelId, system, messages, config, temperatur
58
58
  temperature,
59
59
  maxTokens,
60
60
  tools,
61
+ toolChoice,
61
62
  config: providerCfg,
62
63
  });
63
64
  }
@@ -10,7 +10,7 @@ function getKey(config) {
10
10
  export default {
11
11
  id: "openai",
12
12
 
13
- async chat({ system, messages, model, temperature = 1.0, maxTokens = 1024, config = {} }) {
13
+ async chat({ system, messages, model, temperature = 1.0, maxTokens = 1024, config = {}, tools, toolChoice }) {
14
14
  const key = getKey(config);
15
15
  if (!key) throw new Error("openai: no api_key (set OPENAI_API_KEY or engines.openai.api_key)");
16
16
  if (!model) throw new Error("openai: model required");
@@ -31,6 +31,20 @@ export default {
31
31
  max_tokens: maxTokens,
32
32
  };
33
33
 
34
+ // Tool use support
35
+ if (tools && tools.length > 0) {
36
+ body.tools = tools;
37
+ // toolChoice="required" forces the model to call at least one tool,
38
+ // preventing empty acknowledgment responses.
39
+ if (toolChoice === "required") {
40
+ body.tool_choice = "required";
41
+ } else if (toolChoice === "any") {
42
+ body.tool_choice = "required"; // OpenAI uses "required" for "any"
43
+ } else if (toolChoice && typeof toolChoice === "object") {
44
+ body.tool_choice = toolChoice;
45
+ }
46
+ }
47
+
34
48
  const res = await fetch(API_BASE, {
35
49
  method: "POST",
36
50
  headers: {
@@ -45,9 +59,15 @@ export default {
45
59
  `openai ${res.status}: ${json?.error?.message || JSON.stringify(json)}`
46
60
  );
47
61
  }
48
- const text = json.choices?.[0]?.message?.content || "";
62
+
63
+ const choice = json.choices?.[0];
64
+ const text = choice?.message?.content || "";
65
+ const toolCalls = choice?.message?.tool_calls;
66
+
49
67
  return {
50
68
  text,
69
+ tool_calls: toolCalls?.length > 0 ? toolCalls : undefined,
70
+ finish_reason: choice?.finish_reason,
51
71
  usage: {
52
72
  input_tokens: json.usage?.prompt_tokens || 0,
53
73
  output_tokens: json.usage?.completion_tokens || 0,