@zihin/agent-client 0.6.1 → 0.8.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/dist/index.js CHANGED
@@ -29,6 +29,7 @@ __export(index_exports, {
29
29
  classifyStreamErrorKind: () => classifyStreamErrorKind,
30
30
  createClient: () => createClient,
31
31
  errorFromDoneChunk: () => errorFromDoneChunk,
32
+ errorFromOutcome: () => errorFromOutcome,
32
33
  errorFromStreamChunk: () => errorFromStreamChunk,
33
34
  isCommercialBlock: () => isCommercialBlock,
34
35
  sanitizeApiError: () => sanitizeApiError,
@@ -422,6 +423,8 @@ function errorFromResponse(status, body, meta = {}) {
422
423
  retryable: false
423
424
  });
424
425
  }
426
+ if (code === "PLAN_LIMIT_REACHED")
427
+ return new ZihinAgentError("blocked", message || "This feature is not available on your Zihin plan.", { ...base, retryable: false });
425
428
  if (status === 401) return new ZihinAgentError("auth", message || "Authentication failed.", base);
426
429
  if (status === 403 && str(rec(rec(rec(body).error).details).reason) === "AGENT_ACCESS_DENIED")
427
430
  return new ZihinAgentError(
@@ -518,7 +521,7 @@ function resolveConfig(config) {
518
521
  if (typeof (config.fetch ?? globalThis.fetch) !== "function") {
519
522
  throw new ZihinAgentError(
520
523
  "config",
521
- "Global fetch is unavailable in this runtime. Use Node >=18 or pass a `fetch` implementation in the client config."
524
+ "Global fetch is unavailable in this runtime. Use Node >=20.16 or pass a `fetch` implementation in the client config."
522
525
  );
523
526
  }
524
527
  return {
@@ -561,15 +564,15 @@ function buildHeaders(apiKey, extra = {}) {
561
564
  ...extra
562
565
  };
563
566
  }
564
- function delay(ms, signal) {
567
+ function delay(ms, signal, meta) {
565
568
  return new Promise((resolve, reject) => {
566
569
  if (signal?.aborted) {
567
- reject(new ZihinAgentError("aborted", "Request was aborted by the caller."));
570
+ reject(new ZihinAgentError("aborted", "Request was aborted by the caller.", meta));
568
571
  return;
569
572
  }
570
573
  const onAbort = () => {
571
574
  clearTimeout(timer);
572
- reject(new ZihinAgentError("aborted", "Request was aborted by the caller."));
575
+ reject(new ZihinAgentError("aborted", "Request was aborted by the caller.", meta));
573
576
  };
574
577
  const timer = setTimeout(() => {
575
578
  if (signal) signal.removeEventListener("abort", onAbort);
@@ -659,7 +662,7 @@ async function requestBuffered(args) {
659
662
  }
660
663
  d.dispose();
661
664
  args.onRetry?.({ endpoint, attempt: attempt + 1, delayMs: wait, reason: "status", status: res.status });
662
- await delay(wait, args.callerSignal);
665
+ await delay(wait, args.callerSignal, { endpoint, attempts: attempt + 1 });
663
666
  continue;
664
667
  }
665
668
  const text = peeked ?? await res.text();
@@ -678,7 +681,7 @@ async function requestBuffered(args) {
678
681
  const wait = backoffMs(attempt);
679
682
  if (budget.overheadLeft() <= wait) throw lastError;
680
683
  args.onRetry?.({ endpoint, attempt: attempt + 1, delayMs: wait, reason: "network" });
681
- await delay(wait, args.callerSignal);
684
+ await delay(wait, args.callerSignal, { endpoint, attempts: attempt + 1 });
682
685
  }
683
686
  }
684
687
  throw lastError ?? new ZihinAgentError("unknown", "Max retries exceeded.", { endpoint });
@@ -687,6 +690,7 @@ async function openStream(args) {
687
690
  const endpoint = endpointLabel("POST", args.url);
688
691
  const doFetch = resolveFetch(args.fetchImpl);
689
692
  const budget = budgetTracker({ ...args, endpoint });
693
+ const policy = args.idempotent ? "idempotent" : "side-effecting";
690
694
  for (let attempt = 0; attempt <= args.maxRetries; attempt++) {
691
695
  const d = createDeadline(args.timeoutMs, args.callerSignal);
692
696
  try {
@@ -698,7 +702,7 @@ async function openStream(args) {
698
702
  redirect: "error",
699
703
  cache: "no-store"
700
704
  });
701
- let wait = shouldRetryStatus(response.status, "side-effecting") && attempt < args.maxRetries ? retryAfterMs(response.headers) ?? backoffMs(attempt) : null;
705
+ let wait = shouldRetryStatus(response.status, policy) && attempt < args.maxRetries ? retryAfterMs(response.headers) ?? backoffMs(attempt) : null;
702
706
  if (response.status === 429) {
703
707
  const text = await response.text().catch(() => "");
704
708
  const body = parseBody(text);
@@ -708,7 +712,7 @@ async function openStream(args) {
708
712
  }
709
713
  d.dispose();
710
714
  args.onRetry?.({ endpoint, attempt: attempt + 1, delayMs: wait, reason: "status", status: 429 });
711
- await delay(wait, args.callerSignal);
715
+ await delay(wait, args.callerSignal, { endpoint, attempts: attempt + 1 });
712
716
  continue;
713
717
  }
714
718
  if (wait !== null && budget.overheadLeft() > wait) {
@@ -724,7 +728,7 @@ async function openStream(args) {
724
728
  reason: "status",
725
729
  status: response.status
726
730
  });
727
- await delay(wait, args.callerSignal);
731
+ await delay(wait, args.callerSignal, { endpoint, attempts: attempt + 1 });
728
732
  continue;
729
733
  }
730
734
  return { response, deadline: d, endpoint };
@@ -735,7 +739,14 @@ async function openStream(args) {
735
739
  if (d.timedOut()) throw budget.expired(attempt + 1);
736
740
  if (d.abortedByCaller())
737
741
  throw new ZihinAgentError("aborted", "Request was aborted by the caller.", meta);
738
- throw errorFromThrow(err, budget.reported, meta);
742
+ const netErr = errorFromThrow(err, budget.reported, meta);
743
+ const wait = backoffMs(attempt);
744
+ if (policy === "idempotent" && netErr.retryable && attempt < args.maxRetries && budget.overheadLeft() > wait) {
745
+ args.onRetry?.({ endpoint, attempt: attempt + 1, delayMs: wait, reason: "network" });
746
+ await delay(wait, args.callerSignal, { endpoint, attempts: attempt + 1 });
747
+ continue;
748
+ }
749
+ throw netErr;
739
750
  }
740
751
  }
741
752
  throw new ZihinAgentError("unknown", "Max retries exceeded.", { endpoint });
@@ -947,6 +958,15 @@ function suggestionsFromBlocks(blocks) {
947
958
  const out = labels.filter((l) => typeof l === "string" && l.trim().length > 0 && l.length <= MAX_SUGGESTION_CHARS).slice(0, MAX_SUGGESTIONS);
948
959
  return out.length ? out : void 0;
949
960
  }
961
+ function turnIds(d) {
962
+ return {
963
+ executionId: safeId(d.execution_id),
964
+ clientMessageId: safeId(d.client_message_id),
965
+ idempotentReplay: d.idempotent_replay === true ? true : void 0,
966
+ userMessageId: safeId(d.user_message_id),
967
+ assistantMessageId: safeId(d.assistant_message_id)
968
+ };
969
+ }
950
970
  function mapEvent(e) {
951
971
  const d = rec(e.data);
952
972
  switch (e.event) {
@@ -956,6 +976,7 @@ function mapEvent(e) {
956
976
  requestId: safeId(d.request_id),
957
977
  agentId: str(d.agent_id),
958
978
  sessionId: safeId(d.session_id),
979
+ ...turnIds(d),
959
980
  acceptedFields: parseAcceptedFields(d.accepted_fields),
960
981
  ignoredFields: parseIgnoredFields(d.ignored_fields),
961
982
  data: e.data
@@ -978,6 +999,8 @@ function mapEvent(e) {
978
999
  };
979
1000
  case "action_proposed":
980
1001
  return { type: "action_proposed", data: e.data };
1002
+ case "action_forwarded":
1003
+ return { type: "action_forwarded", actionId: safeId(d.action_id), data: e.data };
981
1004
  case "token":
982
1005
  return { type: "token", content: str(d.content) ?? "", data: e.data };
983
1006
  case "warning":
@@ -992,6 +1015,7 @@ function mapEvent(e) {
992
1015
  timeoutClock: safeLabel(d.timeout_clock),
993
1016
  truncated: typeof d.truncated === "boolean" ? d.truncated : void 0,
994
1017
  finishReason: str(d.finish_reason),
1018
+ ...turnIds(d),
995
1019
  data: e.data
996
1020
  };
997
1021
  case "error": {
@@ -1014,6 +1038,7 @@ function mapEvent(e) {
1014
1038
  sources: Array.isArray(d.sources) ? d.sources : void 0,
1015
1039
  blocks: Array.isArray(d.blocks) ? d.blocks : void 0,
1016
1040
  suggestions: suggestionsFromBlocks(d.blocks),
1041
+ ...turnIds(d),
1017
1042
  data: e.data
1018
1043
  };
1019
1044
  case "metrics": {
@@ -1036,6 +1061,7 @@ function buildRequestBody(params, normalizedTimeoutMs) {
1036
1061
  const body = {};
1037
1062
  if (params.message !== void 0) body.message = params.message;
1038
1063
  if (params.sessionId) body.session_id = params.sessionId;
1064
+ if (params.clientMessageId) body.client_message_id = params.clientMessageId;
1039
1065
  if (params.consumerKey) body.consumer_key = params.consumerKey;
1040
1066
  if (params.consumer?.name !== void 0) body.consumer_display_name = params.consumer.name;
1041
1067
  if (params.capabilities?.blocks?.length) body.capabilities = { blocks: [...params.capabilities.blocks] };
@@ -1117,6 +1143,7 @@ var PROTOTYPE_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "proto
1117
1143
  var MAX_CONSUMER_KEY_CHARS = 200;
1118
1144
  var MAX_CONSUMER_NAME_CHARS = 255;
1119
1145
  var MAX_CLIENT_BLOCKS = 10;
1146
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1120
1147
  function snapshotStructuredInput(params) {
1121
1148
  const snap = (v) => isPlainObject(v) ? Object.fromEntries(Object.entries(v).filter(([, x]) => x !== void 0)) : v;
1122
1149
  return {
@@ -1156,6 +1183,9 @@ function isScalar(v) {
1156
1183
  return v === null || typeof v === "string" || typeof v === "boolean" || typeof v === "number" && Number.isFinite(v);
1157
1184
  }
1158
1185
  function validateStructuredInput(params) {
1186
+ if (params.clientMessageId !== void 0 && (typeof params.clientMessageId !== "string" || !UUID_RE.test(params.clientMessageId))) {
1187
+ throw new ZihinAgentError("config", "`clientMessageId` must be a UUID (e.g. from `crypto.randomUUID()`).");
1188
+ }
1159
1189
  if (params.consumerKey !== void 0 && (typeof params.consumerKey !== "string" || !params.consumerKey.trim())) {
1160
1190
  throw new ZihinAgentError("config", "`consumerKey` must be a non-empty string.");
1161
1191
  }
@@ -1233,6 +1263,7 @@ function requestedInputFields(params) {
1233
1263
  if (params.context) out.push("context");
1234
1264
  if (params.bindings) out.push("bindings");
1235
1265
  if (params.sessionFrom?.length) out.push("session_from");
1266
+ if (params.clientMessageId) out.push("client_message_id");
1236
1267
  return out;
1237
1268
  }
1238
1269
  function checkInputCapability(params, chunk) {
@@ -1269,7 +1300,7 @@ function checkInputCapability(params, chunk) {
1269
1300
  );
1270
1301
  }
1271
1302
  }
1272
- var TURN_EVENTS = /* @__PURE__ */ new Set(["phase", "thinking", "tool", "action_proposed", "token", "response", "metrics", "done"]);
1303
+ var TURN_EVENTS = /* @__PURE__ */ new Set(["phase", "thinking", "tool", "action_proposed", "action_forwarded", "token", "response", "metrics", "done"]);
1273
1304
  async function* streamAgent(cfg, rawParams) {
1274
1305
  const params = snapshotStructuredInput(rawParams);
1275
1306
  validateParams(params);
@@ -1288,7 +1319,18 @@ async function* streamAgent(cfg, rawParams) {
1288
1319
  maxRetries: cfg.maxRetries,
1289
1320
  callerSignal: params.signal,
1290
1321
  fetchImpl: cfg.fetchImpl,
1291
- onRetry: cfg.onRetry
1322
+ onRetry: cfg.onRetry,
1323
+ // Retry do POST só ANTES dos headers chegarem. O que o torna seguro é a ordem do BE: o SSE faz
1324
+ // `flushHeaders()` antes de reservar o id e antes de qualquer trabalho do turno, então falhar
1325
+ // sem headers quase sempre significa que o handler nem começou. A chave cobre o resto: se o 1º
1326
+ // POST chegou a rodar, o reenvio dá SESSION_BUSY (em voo) ou replay (COMPLETED/LENGTH). A chave
1327
+ // NÃO protege turno cortado no meio: o BE cancela e libera o id, e o reenvio roda de novo com as
1328
+ // tools — por isso o erro mid-stream sai `retryable: false`. Liga antes de ver `accepted_fields`
1329
+ // (chega no `metadata`): contra BE antigo o retry pré-headers acontece sem a chave valer —
1330
+ // aceitável pela mesma ordem (headers antes do trabalho), mas documentado no README.
1331
+ // Resíduo não verificado: proxy que bufferiza headers e devolve 502/504 depois do BE já ter
1332
+ // começado (X-Accel-Buffering: no torna isso improvável).
1333
+ idempotent: !!params.clientMessageId
1292
1334
  });
1293
1335
  try {
1294
1336
  if (!response.ok) {
@@ -1299,7 +1341,7 @@ async function* streamAgent(cfg, rawParams) {
1299
1341
  }
1300
1342
  throw errorFromResponse(response.status, parseBody(text), { endpoint });
1301
1343
  }
1302
- const onAbortError = () => deadline.timedOut() ? new ZihinAgentError("timeout", `Stream timed out after ${reportedMs}ms.`, { endpoint }) : deadline.abortedByCaller() ? new ZihinAgentError("aborted", "Stream aborted by the caller.", { endpoint }) : new ZihinAgentError("network", "Stream connection interrupted.", { endpoint });
1344
+ const onAbortError = () => deadline.timedOut() ? new ZihinAgentError("timeout", `Stream timed out after ${reportedMs}ms.`, { endpoint }) : deadline.abortedByCaller() ? new ZihinAgentError("aborted", "Stream aborted by the caller.", { endpoint }) : new ZihinAgentError("network", "Stream connection interrupted.", { endpoint, retryable: false });
1303
1345
  for await (const e of parseSse(response, {
1304
1346
  signal: params.signal,
1305
1347
  onAbortError,
@@ -1322,16 +1364,62 @@ async function* streamAgent(cfg, rawParams) {
1322
1364
  function classifyStreamErrorKind(code, timedOut) {
1323
1365
  if (timedOut === true) return "timeout";
1324
1366
  if (code === "VALIDATION_ERROR") return "config";
1367
+ const known = own(STREAM_ERROR_CODES, code);
1368
+ if (known) return known.kind;
1325
1369
  if (timedOut === false) return "server";
1326
1370
  return /TIMEOUT/i.test(code) ? "timeout" : "server";
1327
1371
  }
1328
- function errorFromStreamChunk(chunk) {
1372
+ var STREAM_ERROR_CODES = {
1373
+ SESSION_BUSY: {
1374
+ kind: "blocked",
1375
+ message: "Another turn is still running in this session (or the same clientMessageId is still in flight); retry after it finishes.",
1376
+ retryable: true
1377
+ },
1378
+ IDEMPOTENCY_CONFLICT: {
1379
+ kind: "config",
1380
+ message: "This `clientMessageId` was already used with a different message \u2014 use a new UUID per message."
1381
+ },
1382
+ // `keepServerMessage`: a frase do BE diz o que foi e o que NÃO foi feito (tools que rodaram) —
1383
+ // o integrador precisa dela para decidir se reenvia.
1384
+ CONTEXT_LIMIT: {
1385
+ kind: "blocked",
1386
+ message: "The conversation gathered more context than the model can process; send a smaller request or start a new session.",
1387
+ keepServerMessage: true
1388
+ },
1389
+ CONFLICT: {
1390
+ kind: "blocked",
1391
+ message: "The declared consumer identity does not match the one already bound to this session."
1392
+ },
1393
+ NOT_FOUND: {
1394
+ kind: "not_found",
1395
+ message: "Agent not found, inactive, or not accessible with this API key \u2014 check the agent ID and that the key belongs to the same Zihin workspace."
1396
+ },
1397
+ // Replay idempotente sem resposta recuperável. Pode persistir por 24 h (turno gravado sem
1398
+ // mensagem do assistente): retentar com o MESMO id bate no mesmo erro, por isso não é
1399
+ // retentável — o aviso diz a saída (novo `clientMessageId`).
1400
+ SERVICE_UNAVAILABLE: {
1401
+ kind: "server",
1402
+ message: "This message was already answered, but the stored answer could not be read; resend with a new clientMessageId.",
1403
+ retryable: false
1404
+ },
1405
+ // Defensivo: não há ponto de emissão in-stream observado no BE @ 9ec79cfc.
1406
+ RATE_LIMIT_EXCEEDED: { kind: "rate_limit", retryable: true },
1407
+ QUOTA_EXCEEDED: { kind: "quota_exceeded" },
1408
+ TRIAL_EXPIRED: { kind: "trial_expired" },
1409
+ NO_ACTIVE_SUBSCRIPTION: { kind: "no_subscription" }
1410
+ };
1411
+ function errorFromStreamChunk(chunk, ctx = {}) {
1329
1412
  const code = chunk.code ?? "AGENT_ERROR";
1330
1413
  const kind = classifyStreamErrorKind(code, chunk.timedOut);
1331
- return new ZihinAgentError(kind, sanitizeApiError(chunk.message ?? code).message || code, {
1414
+ const known = chunk.timedOut === true ? void 0 : own(STREAM_ERROR_CODES, code);
1415
+ const serverMsg = chunk.message ? sanitizeApiError(chunk.message).message.replace(/<[^>]*>/g, "").slice(0, 300) : "";
1416
+ const message = known?.message ? known.keepServerMessage && serverMsg ? `${known.message} Server: ${serverMsg}` : known.message : serverMsg || code;
1417
+ return new ZihinAgentError(kind, message, {
1332
1418
  code,
1333
1419
  requestId: chunk.requestId,
1334
- retryable: false
1420
+ executionId: safeId(ctx.executionId),
1421
+ sessionId: safeId(ctx.sessionId),
1422
+ retryable: known?.retryable ?? false
1335
1423
  });
1336
1424
  }
1337
1425
  var FAILED_OUTCOMES = {
@@ -1352,8 +1440,32 @@ var FAILED_OUTCOMES = {
1352
1440
  CONFLICT: {
1353
1441
  kind: "blocked",
1354
1442
  message: "The declared consumer identity does not match the one already bound to this session."
1443
+ },
1444
+ // O provedor do modelo recusou por política (BE `4cea8d53`, 08/10/2026). O `content` é um texto
1445
+ // neutro FIXO da plataforma ("Não consigo ajudar com esse pedido…"), não uma resposta: o BE
1446
+ // registra a execução como erro. Publicá-lo como resposta era a falha silenciosa da 0.6.
1447
+ REFUSED: {
1448
+ kind: "blocked",
1449
+ message: "The model provider refused this request under its content policy; the agent did not answer."
1450
+ },
1451
+ // No `/stream` chega como `event: error` (ver STREAM_ERROR_CODES); no webhook sync e em
1452
+ // backends futuros pode vir como desfecho.
1453
+ CONTEXT_LIMIT: {
1454
+ kind: "blocked",
1455
+ message: "The conversation gathered more context than the model can process; send a smaller request or start a new session."
1355
1456
  }
1356
1457
  };
1458
+ function errorFromOutcome(outcome, ctx = {}) {
1459
+ const label = safeLabel(outcome);
1460
+ if (!label) return void 0;
1461
+ ctx = { ...ctx, executionId: safeId(ctx.executionId), sessionId: safeId(ctx.sessionId) };
1462
+ if (label === "TIMEOUT") {
1463
+ return new ZihinAgentError("timeout", "The agent turn timed out on the server.", { ...ctx, code: "TURN_TIMEOUT", retryable: false });
1464
+ }
1465
+ const failed = own(FAILED_OUTCOMES, label);
1466
+ if (!failed) return void 0;
1467
+ return new ZihinAgentError(failed.kind, failed.message, { ...ctx, code: label, retryable: failed.retryable ?? false });
1468
+ }
1357
1469
  var PAUSED_BLOCKS = /* @__PURE__ */ new Set(["agent_paused", "agent_archived"]);
1358
1470
  var ERROR_BLOCKS = {
1359
1471
  ownership_denied: {
@@ -1410,6 +1522,7 @@ async function invokeAgent(cfg, params) {
1410
1522
  const keepRaw = params.includeRaw === true;
1411
1523
  let responseChunk;
1412
1524
  let metricsChunk;
1525
+ let metadataChunk;
1413
1526
  let metadataSessionId;
1414
1527
  let mintedSessionId;
1415
1528
  let doneChunk;
@@ -1420,12 +1533,16 @@ async function invokeAgent(cfg, params) {
1420
1533
  for await (const chunk of streamAgent(cfg, params)) {
1421
1534
  if (keepRaw) raw.push(chunk);
1422
1535
  if (chunk.type === "error") {
1423
- throw errorFromStreamChunk(chunk);
1536
+ throw errorFromStreamChunk(chunk, {
1537
+ executionId: metadataChunk?.executionId,
1538
+ sessionId: mintedSessionId ?? metadataSessionId ?? params.sessionId
1539
+ });
1424
1540
  }
1425
1541
  if (chunk.type === "response") responseChunk = chunk;
1426
1542
  else if (chunk.type === "metrics") metricsChunk = chunk;
1427
1543
  else if (chunk.type === "token") tokenBuffer += chunk.content;
1428
1544
  else if (chunk.type === "metadata") {
1545
+ metadataChunk = chunk;
1429
1546
  if (chunk.sessionId) metadataSessionId = chunk.sessionId;
1430
1547
  acceptedFields = chunk.acceptedFields;
1431
1548
  ignoredFields = chunk.ignoredFields;
@@ -1434,9 +1551,23 @@ async function invokeAgent(cfg, params) {
1434
1551
  else if (chunk.type === "status" && chunk.blocked) blocked = chunk.blocked;
1435
1552
  }
1436
1553
  const sessionId = mintedSessionId ?? responseChunk?.sessionId ?? metadataSessionId ?? params.sessionId;
1437
- const executionId = metricsChunk?.executionId;
1554
+ const executionId = metadataChunk?.executionId ?? doneChunk?.executionId ?? responseChunk?.executionId ?? metricsChunk?.executionId;
1555
+ const ids = {
1556
+ clientMessageId: doneChunk?.clientMessageId ?? responseChunk?.clientMessageId ?? metadataChunk?.clientMessageId,
1557
+ idempotentReplay: doneChunk?.idempotentReplay ?? responseChunk?.idempotentReplay ?? metadataChunk?.idempotentReplay,
1558
+ userMessageId: doneChunk?.userMessageId ?? responseChunk?.userMessageId,
1559
+ assistantMessageId: doneChunk?.assistantMessageId ?? responseChunk?.assistantMessageId
1560
+ };
1438
1561
  const outcomeError = doneChunk && errorFromDoneChunk(doneChunk, { executionId, sessionId, blocked });
1439
1562
  if (outcomeError) throw outcomeError;
1563
+ const replaySession = responseChunk?.sessionId ?? metadataSessionId;
1564
+ if (ids.idempotentReplay && params.sessionId && replaySession && replaySession.toLowerCase() !== params.sessionId.toLowerCase()) {
1565
+ throw new ZihinAgentError(
1566
+ "config",
1567
+ "This `clientMessageId` was already used in a different session: the server replayed that session's answer. Use a new UUID per message (never a fixed value).",
1568
+ { code: "IDEMPOTENT_REPLAY_OTHER_SESSION", executionId, sessionId: params.sessionId, retryable: false }
1569
+ );
1570
+ }
1440
1571
  const cleanEnd = doneChunk !== void 0;
1441
1572
  if (!responseChunk) {
1442
1573
  if (cleanEnd) {
@@ -1452,6 +1583,7 @@ async function invokeAgent(cfg, params) {
1452
1583
  iterations: metricsChunk?.iterations,
1453
1584
  executionTimeMs: metricsChunk?.executionTimeMs,
1454
1585
  executionId,
1586
+ ...ids,
1455
1587
  acceptedFields,
1456
1588
  ignoredFields,
1457
1589
  raw
@@ -1472,6 +1604,7 @@ async function invokeAgent(cfg, params) {
1472
1604
  iterations: metricsChunk?.iterations,
1473
1605
  executionTimeMs: metricsChunk?.executionTimeMs,
1474
1606
  executionId,
1607
+ ...ids,
1475
1608
  outcome: doneChunk?.outcome,
1476
1609
  truncated: doneChunk?.truncated,
1477
1610
  finishReason: doneChunk?.finishReason,
@@ -1742,22 +1875,40 @@ async function dispatchAgent(cfg, params) {
1742
1875
  }
1743
1876
  const json = parseBody(res.text);
1744
1877
  const j = rec(json);
1878
+ const preTurnOutcome = safeLabel(j.outcome);
1879
+ if (j.success === false && preTurnOutcome && preTurnOutcome !== "ERROR") {
1880
+ const classified = errorFromOutcome(preTurnOutcome, {
1881
+ status: res.status,
1882
+ endpoint: res.endpoint,
1883
+ attempts: res.attempts,
1884
+ executionId: safeId(j.execution_id)
1885
+ });
1886
+ if (classified) throw classified;
1887
+ }
1745
1888
  if (j.success === false) {
1746
1889
  const detail = sanitizeApiError(str(j.error) ?? "").message;
1747
1890
  throw new ZihinAgentError("server", detail || "The agent run failed.", {
1748
1891
  status: res.status,
1749
1892
  endpoint: res.endpoint,
1750
1893
  attempts: res.attempts,
1751
- executionId: str(j.execution_id),
1894
+ executionId: safeId(j.execution_id),
1752
1895
  // Não é transitório: o turno rodou e falhou. Repetir gastaria outra execução.
1753
1896
  retryable: false
1754
1897
  });
1755
1898
  }
1899
+ const outcomeError = errorFromOutcome(str(j.outcome), {
1900
+ status: res.status,
1901
+ endpoint: res.endpoint,
1902
+ attempts: res.attempts,
1903
+ executionId: safeId(j.execution_id)
1904
+ });
1905
+ if (outcomeError) throw outcomeError;
1756
1906
  return {
1757
1907
  accepted: true,
1908
+ outcome: safeLabel(j.outcome),
1758
1909
  triggerId: str(j.trigger_id) ?? params.triggerId,
1759
1910
  agentId: str(j.agent_id),
1760
- executionId: str(j.execution_id),
1911
+ executionId: safeId(j.execution_id),
1761
1912
  agentResponse: str(j.agent_response),
1762
1913
  usage: parseUsage(j.usage),
1763
1914
  iterations: num(j.iterations),
@@ -2084,6 +2235,7 @@ function signRequest(payload, secretKey, timestamp) {
2084
2235
  classifyStreamErrorKind,
2085
2236
  createClient,
2086
2237
  errorFromDoneChunk,
2238
+ errorFromOutcome,
2087
2239
  errorFromStreamChunk,
2088
2240
  isCommercialBlock,
2089
2241
  sanitizeApiError,