@ateam-ai/mcp 0.4.64 → 0.4.66

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 (3) hide show
  1. package/package.json +1 -1
  2. package/src/api.js +23 -1
  3. package/src/tools.js +211 -17
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ateam-ai/mcp",
3
- "version": "0.4.64",
3
+ "version": "0.4.66",
4
4
  "mcpName": "io.github.ariekogan/ateam-mcp",
5
5
  "description": "A-Team MCP Server — build, validate, and deploy multi-agent solutions from any AI environment",
6
6
  "type": "module",
package/src/api.js CHANGED
@@ -173,7 +173,7 @@ export function isExplicitlyAuthenticated(sessionId) {
173
173
  * Record activity on a session — called on every tool call.
174
174
  * Keeps the session alive and updates context for smarter UX.
175
175
  */
176
- export function touchSession(sessionId, { toolName, solutionId, skillId } = {}) {
176
+ export function touchSession(sessionId, { toolName, solutionId, skillId, actorId } = {}) {
177
177
  const session = sessions.get(sessionId);
178
178
  if (!session) return;
179
179
 
@@ -183,6 +183,22 @@ export function touchSession(sessionId, { toolName, solutionId, skillId } = {})
183
183
  if (toolName) session.context.lastToolName = toolName;
184
184
  if (solutionId) session.context.activeSolutionId = solutionId;
185
185
  if (skillId) session.context.lastSkillId = skillId;
186
+ // THE ACTOR IS SESSION STATE, NOT A PER-CALL ARGUMENT.
187
+ //
188
+ // A job belongs to an ACTOR, and Core enforces that on every per-job read.
189
+ // The tenant API key is roleless — it identifies a tenant, i.e. NOBODY — so
190
+ // without an actor a caller is refused reads of jobs it kicked off itself and
191
+ // just listed. Threading actor_id through each tool made five of six job-facing
192
+ // tools forget it (get_chain, chain_status, test_status, test_abort,
193
+ // get_metrics) — and get_chain's own description PROMISED actor scoping its
194
+ // schema could not express. Remembering it here means no tool can forget.
195
+ //
196
+ // Learned from whatever the caller last supplied, and from ateam_conversation's
197
+ // reply, which is where an actor id comes from in the first place. Safe to keep:
198
+ // the Builder applies realActorId() before forwarding, so a generated
199
+ // test_<ts>_<rand> thread key is dropped rather than sent to Core (which 401s on
200
+ // an actor it cannot find). An explicit actor_id on a call still wins. (2026-08-22.)
201
+ if (actorId) session.context.actorId = String(actorId);
186
202
  }
187
203
 
188
204
  /**
@@ -420,6 +436,7 @@ function headers(sessionId) {
420
436
  const h = { "Content-Type": "application/json" };
421
437
  h["x-adas-token"] = session.masterKey;
422
438
  h["X-ADAS-TENANT"] = session.tenant;
439
+ if (session.context?.actorId) h["X-ADAS-ACTOR-ID"] = session.context.actorId;
423
440
  return h;
424
441
  }
425
442
 
@@ -428,6 +445,11 @@ function headers(sessionId) {
428
445
  const h = { "Content-Type": "application/json" };
429
446
  if (tenant) h["X-ADAS-TENANT"] = tenant;
430
447
  if (apiKey) h["X-API-KEY"] = apiKey;
448
+ // The acting actor rides with the tenant on EVERY call — see touchSession.
449
+ // The tenant says WHICH account; the actor says WHO, and per-job reads need
450
+ // both. Adds no authority: Core resolves the actor INSIDE the authenticated
451
+ // tenant and 401s if it is not there.
452
+ if (session?.context?.actorId) h["X-ADAS-ACTOR-ID"] = session.context.actorId;
431
453
  return h;
432
454
  }
433
455
 
package/src/tools.js CHANGED
@@ -1430,6 +1430,14 @@ export const tools = [
1430
1430
  type: "string",
1431
1431
  description: "Optional: get detailed trace for a specific job ID",
1432
1432
  },
1433
+ chain_id: {
1434
+ type: "string",
1435
+ description: "The CHAIN id — what ateam_conversation returns and ateam_chain_status takes. Prefer this: it is the id you actually hold. Resolved to the underlying job for you.",
1436
+ },
1437
+ actor_id: {
1438
+ type: "string",
1439
+ description: "The actor whose job this is. REQUIRED for per-job detail: a job belongs to an actor and Core refuses the detail endpoint without one (the list form does not check). Use the same actor_id you passed to ateam_conversation.",
1440
+ },
1433
1441
  limit: {
1434
1442
  type: "number",
1435
1443
  description: "Max jobs to return (default: 10, max: 50)",
@@ -1446,11 +1454,21 @@ export const tools = [
1446
1454
  monitoring: { safe: true, cost: "cheap", latency_ms_p95: 500, output: "bounded", poll_interval_s: 2,
1447
1455
  note: "safe:false when include_chain:true (that fetches the full tree)." },
1448
1456
  description:
1449
- "Poll the progress of an async skill test. Returns iteration count, tool call steps, status (running/completed/failed), and result when done.\n\n" +
1457
+ "Poll the progress of an async test. Pass chain_id for the WHOLE run (recommended — the root job finishing does NOT mean the run finished; a handoff may still be going). Pass job_id to poll one job alone: iteration count, tool call steps, status, and result when done.\n\n" +
1450
1458
  "Set include_chain:true to ALSO include the full chain tree (every job in the chain, rooted at this job_id, with parent/child linkage). Use when this job dispatched askAnySkill subcalls and you want a single snapshot of the whole multi-skill state instead of polling each child job_id separately.",
1451
1459
  inputSchema: {
1452
1460
  type: "object",
1453
1461
  properties: {
1462
+ chain_id: {
1463
+ type: "string",
1464
+ description:
1465
+ "THE EXECUTION'S IDENTITY — what ateam_conversation returns and what you actually hold. A chain is the whole run: root job + every handoff + every askAnySkill subcall. Prefer this.",
1466
+ },
1467
+ actor_id: {
1468
+ type: "string",
1469
+ description:
1470
+ "Optional. WHO is asking. A job belongs to an actor and Core enforces that on per-job reads, so a tenant key alone is refused. Usually unnecessary — the session remembers the actor from ateam_conversation/ateam_test_skill. Pass it to inspect a job run by a DIFFERENT actor (e.g. a real user's).",
1471
+ },
1454
1472
  solution_id: {
1455
1473
  type: "string",
1456
1474
  description: "The solution ID",
@@ -1461,7 +1479,7 @@ export const tools = [
1461
1479
  },
1462
1480
  job_id: {
1463
1481
  type: "string",
1464
- description: "The job ID returned by ateam_test_skill",
1482
+ description: "ONE job inside the chain, when you want that job alone. Omit and pass chain_id for the whole run — a root job can be 'completed' while a handoff is still running.",
1465
1483
  },
1466
1484
  include_chain: {
1467
1485
  type: "boolean",
@@ -1469,7 +1487,7 @@ export const tools = [
1469
1487
  "If true, includes response.chain — the full chain tree rooted at this job_id (chainJobs[] with parentJobId/relation/depth, executionSteps[] with tool-nesting). Costs one extra Core call. Default false (back-compat).",
1470
1488
  },
1471
1489
  },
1472
- required: ["solution_id", "skill_id", "job_id"],
1490
+ required: ["solution_id"],
1473
1491
  },
1474
1492
  },
1475
1493
  {
@@ -1480,7 +1498,7 @@ export const tools = [
1480
1498
  // degrades. Use once at the end; poll ateam_chain_status instead.
1481
1499
  monitoring: { safe: false, cost: "heavy", output: "grows_with_run", use_instead: "ateam_chain_status" },
1482
1500
  description:
1483
- "Inspect the full chain tree for any job rooted at the given job_id, walking down through every handoff and askAnySkill subcall.\n\n" +
1501
+ "Inspect the full chain tree the whole run rooted at chain_id, walking down through every handoff and askAnySkill subcall.\n\n" +
1484
1502
  "Use when a chain has already run and you want to analyze the structure: which skill called which, how deep the call tree went, which tool inside which job invoked which sub-tool. The two main shapes:\n" +
1485
1503
  " • response.chain.chainJobs[] — one entry per job in the chain. Fields: jobId, skill, status, iteration, depth (0 = root, +1 per askAnySkill subcall hop), relation ('root' | 'subcall' | 'handoff'), parentJobId, parentSkill, goal.\n" +
1486
1504
  " • response.chain.executionSteps[] — every tool call across all chain jobs, tagged with _skill, _jobId, _depth (= job depth), _relation, _parentSkill, _parentJobId, _toolDepth (tool-in-tool nesting via opId/parentOpId).\n\n" +
@@ -1489,16 +1507,26 @@ export const tools = [
1489
1507
  inputSchema: {
1490
1508
  type: "object",
1491
1509
  properties: {
1510
+ chain_id: {
1511
+ type: "string",
1512
+ description:
1513
+ "THE EXECUTION'S IDENTITY — what ateam_conversation returns and what you actually hold. A chain is the whole run: root job + every handoff + every askAnySkill subcall. Prefer this.",
1514
+ },
1515
+ actor_id: {
1516
+ type: "string",
1517
+ description:
1518
+ "Optional. WHO is asking. A job belongs to an actor and Core enforces that on per-job reads, so a tenant key alone is refused. Usually unnecessary — the session remembers the actor from ateam_conversation/ateam_test_skill. Pass it to inspect a job run by a DIFFERENT actor (e.g. a real user's).",
1519
+ },
1492
1520
  job_id: {
1493
1521
  type: "string",
1494
- description: "The root job ID of the chain to inspect (or any job inside the chainCore walks up to the root).",
1522
+ description: "Alias for chain_id. Any job inside the chain works Core walks up to the rootbut you rarely hold one; prefer chain_id.",
1495
1523
  },
1496
1524
  skill_slug: {
1497
1525
  type: "string",
1498
1526
  description: "Optional. The skill slug for the job — speeds up the lookup when the job isn't in memory and must be loaded from storage. Omit if you don't have it; lookup still works but does an extra round-trip.",
1499
1527
  },
1500
1528
  },
1501
- required: ["job_id"],
1529
+ required: [],
1502
1530
  },
1503
1531
  },
1504
1532
  {
@@ -1522,10 +1550,19 @@ export const tools = [
1522
1550
  inputSchema: {
1523
1551
  type: "object",
1524
1552
  properties: {
1553
+ actor_id: {
1554
+ type: "string",
1555
+ description:
1556
+ "Optional. WHO is asking. A job belongs to an actor and Core enforces that on per-job reads, so a tenant key alone is refused. Usually unnecessary — the session remembers the actor from ateam_conversation/ateam_test_skill. Pass it to inspect a job run by a DIFFERENT actor (e.g. a real user's).",
1557
+ },
1525
1558
  chain_id: {
1526
1559
  type: "string",
1527
1560
  description: "The chain id returned by ateam_conversation (the conversation's identity). Any job id in the chain also works — Core resolves the chain aggregate.",
1528
1561
  },
1562
+ job_id: {
1563
+ type: "string",
1564
+ description: "Alias for chain_id — any job in the chain resolves to the chain aggregate. The handler has always accepted it; without this declaration MCP stripped it before the handler could see it.",
1565
+ },
1529
1566
  },
1530
1567
  required: ["chain_id"],
1531
1568
  },
@@ -1573,10 +1610,20 @@ export const tools = [
1573
1610
  name: "ateam_test_abort",
1574
1611
  core: true,
1575
1612
  description:
1576
- "Abort a running skill test. Stops the job execution at the next iteration boundary. (Advanced.)",
1613
+ "Abort a running test. Pass chain_id to abort the WHOLE run — every job in the chain — and get back which ones stopped. Aborting by job_id stops that job only, leaving handoffs running. Stops at the next iteration boundary. (Advanced.)",
1577
1614
  inputSchema: {
1578
1615
  type: "object",
1579
1616
  properties: {
1617
+ chain_id: {
1618
+ type: "string",
1619
+ description:
1620
+ "THE EXECUTION'S IDENTITY — what ateam_conversation returns and what you actually hold. A chain is the whole run: root job + every handoff + every askAnySkill subcall. Prefer this.",
1621
+ },
1622
+ actor_id: {
1623
+ type: "string",
1624
+ description:
1625
+ "Optional. WHO is asking. A job belongs to an actor and Core enforces that on per-job reads, so a tenant key alone is refused. Usually unnecessary — the session remembers the actor from ateam_conversation/ateam_test_skill. Pass it to inspect a job run by a DIFFERENT actor (e.g. a real user's).",
1626
+ },
1580
1627
  solution_id: {
1581
1628
  type: "string",
1582
1629
  description: "The solution ID",
@@ -1587,10 +1634,10 @@ export const tools = [
1587
1634
  },
1588
1635
  job_id: {
1589
1636
  type: "string",
1590
- description: "The job ID to abort",
1637
+ description: "Abort ONE job only. Prefer chain_id: aborting the root leaves handoffs running while reporting the test aborted.",
1591
1638
  },
1592
1639
  },
1593
- required: ["solution_id", "skill_id", "job_id"],
1640
+ required: ["solution_id"],
1594
1641
  },
1595
1642
  },
1596
1643
  {
@@ -1657,6 +1704,11 @@ export const tools = [
1657
1704
  inputSchema: {
1658
1705
  type: "object",
1659
1706
  properties: {
1707
+ actor_id: {
1708
+ type: "string",
1709
+ description:
1710
+ "Optional. WHO is asking. A job belongs to an actor and Core enforces that on per-job reads, so a tenant key alone is refused. Usually unnecessary — the session remembers the actor from ateam_conversation/ateam_test_skill. Pass it to inspect a job run by a DIFFERENT actor (e.g. a real user's).",
1711
+ },
1660
1712
  solution_id: {
1661
1713
  type: "string",
1662
1714
  description: "The solution ID",
@@ -1665,6 +1717,11 @@ export const tools = [
1665
1717
  type: "string",
1666
1718
  description: "Optional: deep analysis for a specific job",
1667
1719
  },
1720
+ chain_id: {
1721
+ type: "string",
1722
+ description:
1723
+ "Optional: deep analysis for the job behind a CHAIN id — what ateam_conversation returns and what you actually hold. Resolved to the job for you.",
1724
+ },
1668
1725
  skill_id: {
1669
1726
  type: "string",
1670
1727
  description: "Optional: recent metrics for a specific skill",
@@ -2824,6 +2881,7 @@ module.exports.default = plugin;
2824
2881
  return files;
2825
2882
  }
2826
2883
 
2884
+
2827
2885
  const handlers = {
2828
2886
  ateam_bootstrap: async () => ({
2829
2887
  runtime: {
@@ -4324,10 +4382,43 @@ const handlers = {
4324
4382
 
4325
4383
  // ─── Developer Tools ────────────────────────────────────────────
4326
4384
 
4327
- ateam_get_execution_logs: async ({ solution_id, skill_id, job_id, limit }, sid) => {
4385
+ ateam_get_execution_logs: async ({ solution_id, skill_id, job_id, chain_id, limit }, sid) => {
4386
+ // A CHAIN IS NOT A JOB — DO NOT RESOLVE ONE DOWN TO THE OTHER.
4387
+ //
4388
+ // Callers hold a chain id (ateam_conversation returns chain_id;
4389
+ // ateam_chain_status takes chain_id) while this tool spoke only job_id, the
4390
+ // inner id nobody ever sees. The tempting fix — look the chain up in the
4391
+ // list and pass its root job on — is WRONG in the direction the system moved:
4392
+ // a chain is root job + every handoff + every askAnySkill subcall, so it
4393
+ // would return ONE job's trace under the name of the whole chain. A partial
4394
+ // trace that calls itself complete is worse than a refusal: it makes the
4395
+ // handoff you are hunting look like it never happened.
4396
+ //
4397
+ // So a chain id goes to the CHAIN endpoint, which returns every job and every
4398
+ // step across the whole tree — the actual "what ran". (2026-08-22.)
4399
+ if (!job_id && chain_id) {
4400
+ const chain = await get(`/deploy/jobs/${encodeURIComponent(chain_id)}/chain`, sid);
4401
+ const jobs = chain?.chain?.chainJobs || [];
4402
+ const steps = chain?.chain?.executionSteps || [];
4403
+ return {
4404
+ ok: true,
4405
+ scope: "chain",
4406
+ chain_id,
4407
+ solution_id,
4408
+ job_count: jobs.length,
4409
+ step_count: steps.length,
4410
+ jobs,
4411
+ steps,
4412
+ _note: `FULL CHAIN: ${jobs.length} job(s) — root + handoffs + subcalls — and ${steps.length} tool call(s) across all of them. Each step carries _skill/_jobId/_depth/_relation so you can see WHICH skill made it. For one job alone, pass job_id.`,
4413
+ };
4414
+ }
4415
+
4328
4416
  const qs = new URLSearchParams();
4329
4417
  if (skill_id) qs.set("skill_id", skill_id);
4330
4418
  if (job_id) qs.set("job_id", job_id);
4419
+ // actor_id is NOT set here. It rides X-ADAS-ACTOR-ID for every tool, from
4420
+ // the session (api.js headers/touchSession) — this tool having its own
4421
+ // private path was how the other five ended up with none at all.
4331
4422
  if (limit) qs.set("limit", String(limit));
4332
4423
  const qsStr = qs.toString() ? `?${qs}` : "";
4333
4424
  return get(`/deploy/solutions/${solution_id}/logs${qsStr}`, sid);
@@ -4597,7 +4688,23 @@ const handlers = {
4597
4688
  return post(`/deploy/voice-test`, body, sid, { timeoutMs: timeoutTotal });
4598
4689
  },
4599
4690
 
4600
- ateam_test_status: async ({ solution_id, skill_id, job_id, include_chain }, sid) => {
4691
+ ateam_test_status: async ({ solution_id, skill_id, job_id, chain_id, include_chain }, sid) => {
4692
+ // Given a CHAIN id, answer about the chain. The per-skill test endpoint below
4693
+ // is per-job and needs a skill — neither of which a caller holding a chain id
4694
+ // has. Routing a chain id there would report the root job's status as if it
4695
+ // were the run's, and a root can be "completed" while a handoff is still
4696
+ // going. Whole-chain status is what "is it done?" actually means.
4697
+ if (chain_id && !job_id) {
4698
+ const data = await get(`/deploy/jobs/${encodeURIComponent(chain_id)}/status`, sid);
4699
+ return { ok: true, scope: "chain", chain_id, ...data };
4700
+ }
4701
+
4702
+ if (!job_id) {
4703
+ throw new Error("Pass chain_id (the whole run — recommended) or job_id. ateam_conversation returns chain_id.");
4704
+ }
4705
+ if (!skill_id) {
4706
+ throw new Error(`job_id "${job_id}" needs skill_id too — the per-job endpoint is scoped by skill. Pass chain_id instead to poll the whole run without knowing which skill ran it.`);
4707
+ }
4601
4708
  // Existing single-job snapshot via Builder (unchanged shape for back-compat).
4602
4709
  const single = await get(`/deploy/solutions/${solution_id}/skills/${skill_id}/test/${job_id}`, sid);
4603
4710
  if (!include_chain) return single;
@@ -4615,8 +4722,14 @@ const handlers = {
4615
4722
  return { ...single, chain };
4616
4723
  },
4617
4724
 
4618
- ateam_get_chain: async ({ job_id, skill_slug }, sid) => {
4619
- if (!job_id) throw new Error("job_id required");
4725
+ ateam_get_chain: async ({ chain_id, job_id, skill_slug }, sid) => {
4726
+ // CHAIN IS THE UNIT. Core resolves either id to the same chain (it walks up
4727
+ // to the root), so the tool takes the id the caller actually holds — the
4728
+ // chain id from ateam_conversation — and treats job_id as an alias for the
4729
+ // rarer case of holding an inner id. Same `chain_id || job_id` shape as
4730
+ // ateam_chain_status, so the two poll/inspect tools take the same argument.
4731
+ const id = chain_id || job_id;
4732
+ if (!id) throw new Error("chain_id required (job_id accepted as an alias)");
4620
4733
  const creds = getCredentials(sid);
4621
4734
  const apiKey = creds?.apiKey;
4622
4735
  if (!apiKey) throw new Error("No api_key in session — call ateam_auth(api_key) first.");
@@ -4625,7 +4738,7 @@ const handlers = {
4625
4738
  const qs = new URLSearchParams();
4626
4739
  if (skill_slug) qs.set("skillSlug", skill_slug);
4627
4740
  const suffix = qs.toString() ? `?${qs}` : "";
4628
- return await get(`/deploy/jobs/${encodeURIComponent(job_id)}/chain${suffix}`, sid);
4741
+ return await get(`/deploy/jobs/${encodeURIComponent(id)}/chain${suffix}`, sid);
4629
4742
  },
4630
4743
 
4631
4744
  // SLIM chain status — the chip-quick poll. Hits Core /api/job/:id/status
@@ -4753,8 +4866,45 @@ const handlers = {
4753
4866
  return { ok: true, generated_at: new Date().toISOString(), counts, widgets: filtered };
4754
4867
  },
4755
4868
 
4756
- ateam_test_abort: async ({ solution_id, skill_id, job_id }, sid) =>
4757
- del(`/deploy/solutions/${solution_id}/skills/${skill_id}/test/${job_id}`, sid),
4869
+ ateam_test_abort: async ({ solution_id, skill_id, job_id, chain_id }, sid) => {
4870
+ // ABORTING THE ROOT DOES NOT ABORT THE RUN. A chain is root + handoffs +
4871
+ // subcalls, each its own job; killing the root leaves the handoff running,
4872
+ // still burning tokens, still writing — while the caller has been told the
4873
+ // test was aborted. So a chain id aborts every job in the chain and REPORTS
4874
+ // each one, rather than quietly doing a fraction of what it claims.
4875
+ if (chain_id && !job_id) {
4876
+ const chain = await get(`/deploy/jobs/${encodeURIComponent(chain_id)}/chain`, sid);
4877
+ const jobs = chain?.chain?.chainJobs || [];
4878
+ if (!jobs.length) {
4879
+ return { ok: false, scope: "chain", chain_id, error: `No jobs found for chain "${chain_id}".`,
4880
+ hint: "The chain may belong to another solution or another actor. ateam_get_execution_logs(chain_id) shows what is visible to you." };
4881
+ }
4882
+ const aborted = [];
4883
+ for (const j of jobs) {
4884
+ const slug = j.skill || skill_id;
4885
+ try {
4886
+ await del(`/deploy/solutions/${solution_id}/skills/${encodeURIComponent(slug)}/test/${encodeURIComponent(j.jobId)}`, sid);
4887
+ aborted.push({ job_id: j.jobId, skill: slug, relation: j.relation, aborted: true });
4888
+ } catch (err) {
4889
+ // A job that was ALREADY finished cannot be aborted — that is not a
4890
+ // failure of the abort, but it must still be visible.
4891
+ aborted.push({ job_id: j.jobId, skill: slug, relation: j.relation, aborted: false, error: err.message });
4892
+ }
4893
+ }
4894
+ return {
4895
+ ok: aborted.some((a) => a.aborted),
4896
+ scope: "chain",
4897
+ chain_id,
4898
+ job_count: jobs.length,
4899
+ aborted_count: aborted.filter((a) => a.aborted).length,
4900
+ jobs: aborted,
4901
+ };
4902
+ }
4903
+ if (!job_id || !skill_id) {
4904
+ throw new Error("Pass chain_id to abort the whole run, or job_id + skill_id to abort one job.");
4905
+ }
4906
+ return del(`/deploy/solutions/${solution_id}/skills/${skill_id}/test/${job_id}`, sid);
4907
+ },
4758
4908
 
4759
4909
  ateam_get_connector_source: async ({ solution_id, connector_id, path }, sid) => {
4760
4910
  const data = await get(`/deploy/solutions/${solution_id}/connectors/${connector_id}/source`, sid);
@@ -4842,7 +4992,38 @@ const handlers = {
4842
4992
  };
4843
4993
  },
4844
4994
 
4845
- ateam_get_metrics: async ({ solution_id, job_id, skill_id }, sid) => {
4995
+ ateam_get_metrics: async ({ solution_id, job_id, chain_id, skill_id }, sid) => {
4996
+ // Same rule as ateam_get_execution_logs: a chain is not a job. Core's
4997
+ // insight is per-job, so a chain is measured by measuring EVERY job in it —
4998
+ // never by silently reporting the root and calling that the chain.
4999
+ if (!job_id && chain_id) {
5000
+ const chain = await get(`/deploy/jobs/${encodeURIComponent(chain_id)}/chain`, sid);
5001
+ const jobs = chain?.chain?.chainJobs || [];
5002
+ const CAP = 10;
5003
+ const measured = jobs.slice(0, CAP);
5004
+ const per_job = [];
5005
+ for (const j of measured) {
5006
+ try {
5007
+ const m = await get(`/deploy/solutions/${solution_id}/metrics?job_id=${encodeURIComponent(j.jobId)}`, sid);
5008
+ per_job.push({ job_id: j.jobId, skill: j.skill, relation: j.relation, depth: j.depth, metrics: m });
5009
+ } catch (err) {
5010
+ // One unreadable job must not hide the rest — say which failed and why.
5011
+ per_job.push({ job_id: j.jobId, skill: j.skill, relation: j.relation, depth: j.depth, error: err.message });
5012
+ }
5013
+ }
5014
+ return {
5015
+ ok: true,
5016
+ scope: "chain",
5017
+ chain_id,
5018
+ solution_id,
5019
+ job_count: jobs.length,
5020
+ measured: per_job.length,
5021
+ // NO SILENT CAPS: if the chain is bigger than we measured, say so here
5022
+ // rather than let the caller read a partial roll-up as the whole chain.
5023
+ truncated: jobs.length > CAP ? `chain has ${jobs.length} jobs; measured the first ${CAP}` : null,
5024
+ per_job,
5025
+ };
5026
+ }
4846
5027
  const qs = new URLSearchParams();
4847
5028
  if (job_id) qs.set("job_id", job_id);
4848
5029
  if (skill_id) qs.set("skill_id", skill_id);
@@ -5545,6 +5726,10 @@ export async function handleToolCall(name, args, sessionId) {
5545
5726
  toolName: name,
5546
5727
  solutionId: args?.solution_id,
5547
5728
  skillId: args?.skill_id,
5729
+ // Remember WHO is acting, so every later per-job read carries it. See the
5730
+ // long note in api.js touchSession: threading actor_id per tool left five of
5731
+ // six job-facing tools unable to express it at all.
5732
+ actorId: args?.actor_id,
5548
5733
  });
5549
5734
 
5550
5735
  // Check auth for tenant-aware operations — requires explicit ateam_auth call.
@@ -5597,6 +5782,15 @@ export async function handleToolCall(name, args, sessionId) {
5597
5782
  try {
5598
5783
  const result = await handler(args, sessionId);
5599
5784
 
5785
+ // An actor id is BORN here: ateam_conversation/ateam_test_skill mint one and
5786
+ // return it, and the docs tell callers to pass it back for multi-turn. Learn
5787
+ // it on the way out so the follow-up ateam_get_execution_logs /
5788
+ // ateam_get_metrics on that very job is not refused for not knowing who ran
5789
+ // it — the single most common dead end when debugging a run.
5790
+ if (result && typeof result === "object" && result.actor_id) {
5791
+ touchSession(sessionId, { actorId: result.actor_id });
5792
+ }
5793
+
5600
5794
  // Stamp WHERE this landed (tenant + app URL) on mutating-tool results, so
5601
5795
  // any client — desktop, mobile, cloud agent — can tell the user where to
5602
5796
  // see the change. Non-fatal + only for object results that don't already