@ateam-ai/mcp 0.4.65 → 0.4.67

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 +40 -1
  3. package/src/tools.js +202 -39
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ateam-ai/mcp",
3
- "version": "0.4.65",
3
+ "version": "0.4.67",
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,39 @@ 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
+ // ONLY A REAL ACTOR. ateam_conversation mints a throwaway THREAD key
202
+ // (test_<ts>_<rand>) for anonymous use and returns it as actor_id — the docs
203
+ // tell callers to pass it back for multi-turn. It is not an actor Core can
204
+ // resolve, and Core 401s the WHOLE REQUEST on an actor it cannot find.
205
+ //
206
+ // I shipped this without the filter and broke ateam_chain_status — the tool
207
+ // every agent polls in a loop — for a chain the same session had just
208
+ // started: 403 "Access denied" became 401 "Authentication required". The
209
+ // commit claimed it was safe because "the Builder applies realActorId()
210
+ // first", which is true only of the two routes I had touched, not of the
211
+ // status/chain pipes. Asserting a safety property that holds locally and
212
+ // assuming it holds everywhere is how the header reached Core unfiltered.
213
+ //
214
+ // So the rule lives at the SOURCE too, matching the Builder's
215
+ // GENERATED_THREAD_ACTOR_RE exactly. (2026-08-22.)
216
+ if (actorId && !/^test_\d+_[a-z0-9]+$/i.test(String(actorId))) {
217
+ session.context.actorId = String(actorId);
218
+ }
186
219
  }
187
220
 
188
221
  /**
@@ -420,6 +453,7 @@ function headers(sessionId) {
420
453
  const h = { "Content-Type": "application/json" };
421
454
  h["x-adas-token"] = session.masterKey;
422
455
  h["X-ADAS-TENANT"] = session.tenant;
456
+ if (session.context?.actorId) h["X-ADAS-ACTOR-ID"] = session.context.actorId;
423
457
  return h;
424
458
  }
425
459
 
@@ -428,6 +462,11 @@ function headers(sessionId) {
428
462
  const h = { "Content-Type": "application/json" };
429
463
  if (tenant) h["X-ADAS-TENANT"] = tenant;
430
464
  if (apiKey) h["X-API-KEY"] = apiKey;
465
+ // The acting actor rides with the tenant on EVERY call — see touchSession.
466
+ // The tenant says WHICH account; the actor says WHO, and per-job reads need
467
+ // both. Adds no authority: Core resolves the actor INSIDE the authenticated
468
+ // tenant and 401s if it is not there.
469
+ if (session?.context?.actorId) h["X-ADAS-ACTOR-ID"] = session.context.actorId;
431
470
  return h;
432
471
  }
433
472
 
package/src/tools.js CHANGED
@@ -1454,11 +1454,21 @@ export const tools = [
1454
1454
  monitoring: { safe: true, cost: "cheap", latency_ms_p95: 500, output: "bounded", poll_interval_s: 2,
1455
1455
  note: "safe:false when include_chain:true (that fetches the full tree)." },
1456
1456
  description:
1457
- "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" +
1458
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.",
1459
1459
  inputSchema: {
1460
1460
  type: "object",
1461
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
+ },
1462
1472
  solution_id: {
1463
1473
  type: "string",
1464
1474
  description: "The solution ID",
@@ -1469,7 +1479,7 @@ export const tools = [
1469
1479
  },
1470
1480
  job_id: {
1471
1481
  type: "string",
1472
- 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.",
1473
1483
  },
1474
1484
  include_chain: {
1475
1485
  type: "boolean",
@@ -1477,7 +1487,7 @@ export const tools = [
1477
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).",
1478
1488
  },
1479
1489
  },
1480
- required: ["solution_id", "skill_id", "job_id"],
1490
+ required: ["solution_id"],
1481
1491
  },
1482
1492
  },
1483
1493
  {
@@ -1488,7 +1498,7 @@ export const tools = [
1488
1498
  // degrades. Use once at the end; poll ateam_chain_status instead.
1489
1499
  monitoring: { safe: false, cost: "heavy", output: "grows_with_run", use_instead: "ateam_chain_status" },
1490
1500
  description:
1491
- "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" +
1492
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" +
1493
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" +
1494
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" +
@@ -1497,16 +1507,26 @@ export const tools = [
1497
1507
  inputSchema: {
1498
1508
  type: "object",
1499
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
+ },
1500
1520
  job_id: {
1501
1521
  type: "string",
1502
- 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.",
1503
1523
  },
1504
1524
  skill_slug: {
1505
1525
  type: "string",
1506
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.",
1507
1527
  },
1508
1528
  },
1509
- required: ["job_id"],
1529
+ required: [],
1510
1530
  },
1511
1531
  },
1512
1532
  {
@@ -1530,10 +1550,19 @@ export const tools = [
1530
1550
  inputSchema: {
1531
1551
  type: "object",
1532
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
+ },
1533
1558
  chain_id: {
1534
1559
  type: "string",
1535
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.",
1536
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
+ },
1537
1566
  },
1538
1567
  required: ["chain_id"],
1539
1568
  },
@@ -1581,10 +1610,20 @@ export const tools = [
1581
1610
  name: "ateam_test_abort",
1582
1611
  core: true,
1583
1612
  description:
1584
- "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.)",
1585
1614
  inputSchema: {
1586
1615
  type: "object",
1587
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
+ },
1588
1627
  solution_id: {
1589
1628
  type: "string",
1590
1629
  description: "The solution ID",
@@ -1595,10 +1634,10 @@ export const tools = [
1595
1634
  },
1596
1635
  job_id: {
1597
1636
  type: "string",
1598
- 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.",
1599
1638
  },
1600
1639
  },
1601
- required: ["solution_id", "skill_id", "job_id"],
1640
+ required: ["solution_id"],
1602
1641
  },
1603
1642
  },
1604
1643
  {
@@ -1665,6 +1704,11 @@ export const tools = [
1665
1704
  inputSchema: {
1666
1705
  type: "object",
1667
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
+ },
1668
1712
  solution_id: {
1669
1713
  type: "string",
1670
1714
  description: "The solution ID",
@@ -1673,6 +1717,11 @@ export const tools = [
1673
1717
  type: "string",
1674
1718
  description: "Optional: deep analysis for a specific job",
1675
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
+ },
1676
1725
  skill_id: {
1677
1726
  type: "string",
1678
1727
  description: "Optional: recent metrics for a specific skill",
@@ -2832,6 +2881,7 @@ module.exports.default = plugin;
2832
2881
  return files;
2833
2882
  }
2834
2883
 
2884
+
2835
2885
  const handlers = {
2836
2886
  ateam_bootstrap: async () => ({
2837
2887
  runtime: {
@@ -4332,33 +4382,43 @@ const handlers = {
4332
4382
 
4333
4383
  // ─── Developer Tools ────────────────────────────────────────────
4334
4384
 
4335
- ateam_get_execution_logs: async ({ solution_id, skill_id, job_id, chain_id, actor_id, limit }, sid) => {
4336
- // CALLERS HOLD A CHAIN ID, NOT A JOB ID. ateam_conversation returns
4337
- // chain_id, ateam_chain_status takes chain_id — but this tool wanted the
4338
- // inner job id, which nobody ever sees. Accept the chain id and resolve it
4339
- // through the LIST form, which already returns chainId per job. No new
4340
- // mechanism: the mapping is in data we were already fetching.
4341
- let resolvedJobId = job_id;
4342
- if (!resolvedJobId && chain_id) {
4343
- const listed = await get(`/deploy/solutions/${solution_id}/logs?limit=50`, sid);
4344
- const match = (listed?.jobs || []).find((j) => j?.chainId === chain_id || j?.id === chain_id);
4345
- if (!match) {
4346
- return {
4347
- ok: false,
4348
- error: `No job found for chain "${chain_id}" in solution "${solution_id}".`,
4349
- hint: "The chain may belong to another solution, or be older than the last 50 jobs. Call this tool without chain_id/job_id to list what exists.",
4350
- };
4351
- }
4352
- resolvedJobId = match.id;
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
+ };
4353
4414
  }
4354
4415
 
4355
4416
  const qs = new URLSearchParams();
4356
4417
  if (skill_id) qs.set("skill_id", skill_id);
4357
- if (resolvedJobId) qs.set("job_id", resolvedJobId);
4358
- // A job belongs to an ACTOR and Core enforces that on the detail endpoint.
4359
- // Without it every job_id lookup is refused including for a job this same
4360
- // call just listed. The tenant key alone is nobody.
4361
- if (actor_id) qs.set("actor_id", actor_id);
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.
4362
4422
  if (limit) qs.set("limit", String(limit));
4363
4423
  const qsStr = qs.toString() ? `?${qs}` : "";
4364
4424
  return get(`/deploy/solutions/${solution_id}/logs${qsStr}`, sid);
@@ -4628,7 +4688,23 @@ const handlers = {
4628
4688
  return post(`/deploy/voice-test`, body, sid, { timeoutMs: timeoutTotal });
4629
4689
  },
4630
4690
 
4631
- 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
+ }
4632
4708
  // Existing single-job snapshot via Builder (unchanged shape for back-compat).
4633
4709
  const single = await get(`/deploy/solutions/${solution_id}/skills/${skill_id}/test/${job_id}`, sid);
4634
4710
  if (!include_chain) return single;
@@ -4646,8 +4722,14 @@ const handlers = {
4646
4722
  return { ...single, chain };
4647
4723
  },
4648
4724
 
4649
- ateam_get_chain: async ({ job_id, skill_slug }, sid) => {
4650
- 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)");
4651
4733
  const creds = getCredentials(sid);
4652
4734
  const apiKey = creds?.apiKey;
4653
4735
  if (!apiKey) throw new Error("No api_key in session — call ateam_auth(api_key) first.");
@@ -4656,7 +4738,7 @@ const handlers = {
4656
4738
  const qs = new URLSearchParams();
4657
4739
  if (skill_slug) qs.set("skillSlug", skill_slug);
4658
4740
  const suffix = qs.toString() ? `?${qs}` : "";
4659
- return await get(`/deploy/jobs/${encodeURIComponent(job_id)}/chain${suffix}`, sid);
4741
+ return await get(`/deploy/jobs/${encodeURIComponent(id)}/chain${suffix}`, sid);
4660
4742
  },
4661
4743
 
4662
4744
  // SLIM chain status — the chip-quick poll. Hits Core /api/job/:id/status
@@ -4784,8 +4866,45 @@ const handlers = {
4784
4866
  return { ok: true, generated_at: new Date().toISOString(), counts, widgets: filtered };
4785
4867
  },
4786
4868
 
4787
- ateam_test_abort: async ({ solution_id, skill_id, job_id }, sid) =>
4788
- 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
+ },
4789
4908
 
4790
4909
  ateam_get_connector_source: async ({ solution_id, connector_id, path }, sid) => {
4791
4910
  const data = await get(`/deploy/solutions/${solution_id}/connectors/${connector_id}/source`, sid);
@@ -4873,7 +4992,38 @@ const handlers = {
4873
4992
  };
4874
4993
  },
4875
4994
 
4876
- 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
+ }
4877
5027
  const qs = new URLSearchParams();
4878
5028
  if (job_id) qs.set("job_id", job_id);
4879
5029
  if (skill_id) qs.set("skill_id", skill_id);
@@ -5576,6 +5726,10 @@ export async function handleToolCall(name, args, sessionId) {
5576
5726
  toolName: name,
5577
5727
  solutionId: args?.solution_id,
5578
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,
5579
5733
  });
5580
5734
 
5581
5735
  // Check auth for tenant-aware operations — requires explicit ateam_auth call.
@@ -5628,6 +5782,15 @@ export async function handleToolCall(name, args, sessionId) {
5628
5782
  try {
5629
5783
  const result = await handler(args, sessionId);
5630
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
+
5631
5794
  // Stamp WHERE this landed (tenant + app URL) on mutating-tool results, so
5632
5795
  // any client — desktop, mobile, cloud agent — can tell the user where to
5633
5796
  // see the change. Non-fatal + only for object results that don't already