stacktrace-cli 0.8.0__py3-none-any.whl → 0.10.0__py3-none-any.whl

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 (80) hide show
  1. stacktrace_cli/__init__.py +1 -1
  2. stacktrace_cli/analysis.py +13 -1
  3. stacktrace_cli/cli.py +3 -1
  4. stacktrace_cli/cloud/setup.sh +5 -3
  5. stacktrace_cli/correlate/acquire.py +7 -1
  6. stacktrace_cli/correlate/composition.py +46 -1
  7. stacktrace_cli/correlate/join.py +102 -147
  8. stacktrace_cli/correlate/observed.py +18 -19
  9. stacktrace_cli/correlate/orchestrate.py +45 -18
  10. stacktrace_cli/cost/__init__.py +6 -0
  11. stacktrace_cli/cost/baseline.py +555 -0
  12. stacktrace_cli/cost/clock.py +40 -0
  13. stacktrace_cli/cost/component_usage.py +324 -0
  14. stacktrace_cli/cost/components.py +143 -0
  15. stacktrace_cli/cost/evidence.py +69 -0
  16. stacktrace_cli/cost/ledger.py +58 -0
  17. stacktrace_cli/cost/measures.py +354 -0
  18. stacktrace_cli/cost/models.py +38 -0
  19. stacktrace_cli/cost/org_baselines.py +258 -0
  20. stacktrace_cli/cost/reasons.py +592 -0
  21. stacktrace_cli/cost/rules.py +662 -0
  22. stacktrace_cli/cost/runs.py +149 -0
  23. stacktrace_cli/cost/tokens.py +69 -0
  24. stacktrace_cli/cost/totals.py +62 -0
  25. stacktrace_cli/cost/usage.py +293 -0
  26. stacktrace_cli/daemon/accounts.py +269 -0
  27. stacktrace_cli/daemon/cli.py +84 -7
  28. stacktrace_cli/daemon/client.py +41 -0
  29. stacktrace_cli/daemon/composition.py +53 -1
  30. stacktrace_cli/daemon/config.py +53 -2
  31. stacktrace_cli/daemon/costs.py +780 -0
  32. stacktrace_cli/daemon/db.py +315 -0
  33. stacktrace_cli/daemon/fleet.py +88 -22
  34. stacktrace_cli/daemon/jobs.py +90 -0
  35. stacktrace_cli/daemon/observer.py +365 -265
  36. stacktrace_cli/daemon/presentation.py +79 -0
  37. stacktrace_cli/daemon/runtime.py +253 -18
  38. stacktrace_cli/daemon/scan.py +32 -1
  39. stacktrace_cli/daemon/server.py +17 -3
  40. stacktrace_cli/daemon/store.py +1494 -22
  41. stacktrace_cli/daemon/usage.py +1126 -0
  42. stacktrace_cli/daemon/webhook.py +2 -2
  43. stacktrace_cli/detector/cache.py +4 -0
  44. stacktrace_cli/detector/deterministic.py +11 -12
  45. stacktrace_cli/detector/finding.py +1 -1
  46. stacktrace_cli/detector/prompts/__init__.py +0 -5
  47. stacktrace_cli/detector/reasoning.py +4 -4
  48. stacktrace_cli/detector/render.py +14 -12
  49. stacktrace_cli/detector/rules.py +55 -4
  50. stacktrace_cli/detector/run.py +2 -2
  51. stacktrace_cli/detector/windows.py +3 -3
  52. stacktrace_cli/monitor/costs.py +650 -0
  53. stacktrace_cli/monitor/offline.py +266 -0
  54. stacktrace_cli/monitor/render.py +132 -30
  55. stacktrace_cli/monitor/server.py +18 -0
  56. stacktrace_cli/monitor/site/app.js +561 -117
  57. stacktrace_cli/monitor/site/cost.js +1008 -0
  58. stacktrace_cli/monitor/site/fonts/OFL.txt +196 -97
  59. stacktrace_cli/monitor/site/fonts/playfair-display-600-italic-latin.woff2 +0 -0
  60. stacktrace_cli/monitor/site/index.html +37 -5
  61. stacktrace_cli/monitor/site/inventory.js +5 -2
  62. stacktrace_cli/monitor/site/styles.css +494 -155
  63. stacktrace_cli/monitor/state.py +5 -0
  64. stacktrace_cli/monitor/watch.py +55 -12
  65. stacktrace_cli/private_state.py +2 -3
  66. stacktrace_cli/remote/client.py +93 -1
  67. stacktrace_cli/remote/detect_payload.py +34 -14
  68. stacktrace_cli/remote/identity.py +39 -31
  69. stacktrace_cli/remote/redact.py +0 -7
  70. stacktrace_cli/remote/spool.py +8 -6
  71. stacktrace_cli/remote/upload_contract.py +136 -4
  72. stacktrace_cli/sessions/access.py +96 -0
  73. stacktrace_cli/sessions/lineage.py +24 -0
  74. stacktrace_cli/sessions/protocols.py +99 -8
  75. stacktrace_cli/telemetry/events.py +3 -0
  76. {stacktrace_cli-0.8.0.dist-info → stacktrace_cli-0.10.0.dist-info}/METADATA +23 -7
  77. stacktrace_cli-0.10.0.dist-info/RECORD +139 -0
  78. stacktrace_cli-0.8.0.dist-info/RECORD +0 -115
  79. {stacktrace_cli-0.8.0.dist-info → stacktrace_cli-0.10.0.dist-info}/WHEEL +0 -0
  80. {stacktrace_cli-0.8.0.dist-info → stacktrace_cli-0.10.0.dist-info}/entry_points.txt +0 -0
@@ -2,7 +2,7 @@
2
2
 
3
3
  import logging as _logging
4
4
 
5
- __version__ = "0.8.0"
5
+ __version__ = "0.10.0"
6
6
 
7
7
  # Only the daemon attaches a handler (ADR-0048). Without this one, a WARNING
8
8
  # logged in any other process would reach Python's last-resort handler and
@@ -62,6 +62,11 @@ class Analysis:
62
62
  #: is not yet countable and that only calls needing no composition are
63
63
  #: listed. `analyse` is always placed.
64
64
  placed: bool = True
65
+ #: `newest_hour` on `analyse_progressively`'s first preview, which reads
66
+ #: only the newest hour of the range; None for the whole range. A page
67
+ #: summing totals needs to tell the two apart, and `placed` cannot: both
68
+ #: previews are unplaced.
69
+ slice: str | None = None
65
70
 
66
71
 
67
72
  #: A count and an optional unit, anchored. Anchored rather than searched
@@ -451,6 +456,7 @@ def analyse_progressively(
451
456
  preview: timedelta | None = PREVIEW_WINDOW,
452
457
  cache: VerdictCache | None = None,
453
458
  attach_advisories: Any = None,
459
+ build_all: Any = None,
454
460
  analyzer: str = NO_ANALYZER,
455
461
  jev_key: str | None = None,
456
462
  history: DriftHistory | None = None,
@@ -463,6 +469,8 @@ def analyse_progressively(
463
469
  `history` is accepted and unused: this path never requests reasoning, so
464
470
  it never adds a point. It is here so the watcher can forward one options
465
471
  dict to both entry points (the test in `tests/monitor/test_watch.py`).
472
+ `build_all` is `analyse`'s own seam, forwarded for the same reason: the
473
+ offline monitor (`monitor/offline.py`) passes one that builds nothing.
466
474
 
467
475
  `analyse` answers once, which is right for a command that prints and exits
468
476
  and wrong for a page meant to look alive. Measured over 339 real sessions
@@ -510,7 +518,10 @@ def analyse_progressively(
510
518
  cutoff = window_end - preview
511
519
  # A window already narrower than the preview is read once, not twice.
512
520
  if cutoff > window_start:
513
- yield _unplaced(read(cutoff), window_start=window_start, window_end=window_end)
521
+ yield replace(
522
+ _unplaced(read(cutoff), window_start=window_start, window_end=window_end),
523
+ slice="newest_hour",
524
+ )
514
525
 
515
526
  sessions = read(window_start)
516
527
  if preview is not None:
@@ -521,6 +532,7 @@ def analyse_progressively(
521
532
  bom_paths=bom_paths,
522
533
  project_map=project_map,
523
534
  attach_advisories=attach_advisories,
535
+ build_all=build_all,
524
536
  ).view
525
537
 
526
538
  def _stage(run: DetectorRun, sessions: tuple[CorrelatedSession, ...]) -> Analysis:
stacktrace_cli/cli.py CHANGED
@@ -411,7 +411,9 @@ def detect(
411
411
  infrastructure change beyond what the user asked for are security; why
412
412
  the agent stopped, and who can unblock it, is reliability.
413
413
  The two families carry their own severity ladders, because a blocked agent
414
- and a leaked credential cannot share one (ADR-0010).
414
+ and a leaked credential cannot share one (ADR-0010). Cost findings are the
415
+ daemon's, which judges a whole day and a 30-day baseline (ADR-0097);
416
+ `stacktrace findings` shows them.
415
417
 
416
418
  Both stages read the record on this machine. Neither needs a model, a
417
419
  credential or an account, and on a default run nothing derived from a
@@ -38,8 +38,8 @@
38
38
  # home, because that home does not exist until this run creates it.
39
39
  #
40
40
  # Options (all optional):
41
- # --asset-id VALUE use this asset id instead of minting one; also taken
42
- # from STACKTRACE_ASSET_EXTERNAL_ID in the environment
41
+ # --asset-id VALUE use this asset id instead of minting a bare UUID; also
42
+ # taken from STACKTRACE_ASSET_EXTERNAL_ID in the environment
43
43
  # --root DIR treat DIR as / (for testing)
44
44
  #
45
45
  # The remote token and API URL, and the Slack webhook URL, are read by the boot
@@ -82,7 +82,9 @@ _uuid() {
82
82
  }
83
83
 
84
84
  DISPLAY_PREFIX="cloud"
85
- [ -n "$ASSET_ID" ] || ASSET_ID="cloud-$(_uuid)"
85
+ # A bare UUID, like every key `remote/identity.py` mints (ADR-0105). The kind of
86
+ # box is the label's business (asset-display-prefix below), not the key's.
87
+ [ -n "$ASSET_ID" ] || ASSET_ID="$(_uuid)"
86
88
  # remote/identity.py:_is_identifier rejects whitespace, non-printable
87
89
  # characters, and anything over 255 characters. A value this accepts but the
88
90
  # runtime rejects is worse than no file: every session reads it, rejects it,
@@ -39,6 +39,7 @@ from typing import Any
39
39
  from stacktrace_cli.correlate.composition import (
40
40
  Advisory,
41
41
  Composition,
42
+ composable,
42
43
  composition_from_bom,
43
44
  reject_malformed_shape,
44
45
  root_property,
@@ -234,8 +235,13 @@ def build_bom(
234
235
 
235
236
  Returns None rather than raising when nothing was produced: a machine can run
236
237
  one agent kind and not another, which is a fact about the machine rather than
237
- a failure to look.
238
+ a failure to look. A kind OpenACA does not know is the same answer for the
239
+ same reason -- sessions can come from an agent whose setup OpenACA cannot
240
+ describe yet, and those sessions resolve as having no composition rather than
241
+ failing every other kind's run.
238
242
  """
243
+ if not composable(agent_kind):
244
+ return None
239
245
  with tempfile.TemporaryDirectory() as scratch:
240
246
  directory = Path(scratch)
241
247
  argv = [
@@ -24,11 +24,25 @@ so a legitimate flat BOM resolves instead of being rejected.
24
24
 
25
25
  from __future__ import annotations
26
26
 
27
+ import json
27
28
  from dataclasses import dataclass, field
28
29
  from datetime import datetime
29
30
  from typing import Any
30
31
 
31
- from openaca.core import bom_components_from_cyclonedx
32
+ from openaca.core import KindSelectionError, bom_components_from_cyclonedx, validate_kind_selection
33
+
34
+
35
+ def composable(agent_kind: str) -> bool:
36
+ """Whether OpenACA can describe this agent kind's setup at all.
37
+
38
+ Asked through the facade's own check rather than a list of ours, so a kind
39
+ OpenACA learns reaches this package with the pin and no change here.
40
+ """
41
+ try:
42
+ validate_kind_selection(agent_kind, None)
43
+ except KindSelectionError:
44
+ return False
45
+ return True
32
46
 
33
47
 
34
48
  @dataclass(frozen=True)
@@ -126,6 +140,12 @@ class Composition:
126
140
  #: one agent as two rows. Defaulted rather than positional so it sits with
127
141
  #: the fields a document may omit, though it belongs beside `agent_kind`.
128
142
  agent_instance_id: str | None = None
143
+ #: The sub-agent types and skills the agent provides itself, as OpenACA
144
+ #: lists them on the BOM root (its ADR-0076). Never components: nothing
145
+ #: installed them, so `find` never answers with one. Empty for a BOM that
146
+ #: lists none, which is every BOM OpenACA wrote before it listed them.
147
+ builtin_agents: frozenset[str] = frozenset()
148
+ builtin_skills: frozenset[str] = frozenset()
129
149
 
130
150
  def find(self, component_type: str, name: str) -> tuple[Component, ...]:
131
151
  """Every component of this type and name, in BOM order.
@@ -181,6 +201,8 @@ def composition_from_bom(
181
201
  advisories=advisories or {},
182
202
  advisories_checked=advisories is not None,
183
203
  agent_instance_id=_declared_instance_id(document),
204
+ builtin_agents=_declared_builtins(document, _BUILTIN_AGENTS_PROPERTY),
205
+ builtin_skills=_declared_builtins(document, _BUILTIN_SKILLS_PROPERTY),
184
206
  )
185
207
 
186
208
 
@@ -211,6 +233,29 @@ def _declared_instance_id(document: dict[str, Any]) -> str | None:
211
233
  return declared if declared and declared.strip() else None
212
234
 
213
235
 
236
+ _BUILTIN_AGENTS_PROPERTY = "openaca:builtin_agents"
237
+ _BUILTIN_SKILLS_PROPERTY = "openaca:builtin_skills"
238
+
239
+
240
+ def _declared_builtins(document: dict[str, Any], name: str) -> frozenset[str]:
241
+ """A JSON array of names on the root, or nothing.
242
+
243
+ A malformed list reads as none, as a blank `openaca:agent_id` does: each
244
+ built-in call then reads as absent, which is visible, while rejecting the
245
+ document would lose every other call's answer with it.
246
+ """
247
+ declared = root_property(document, name)
248
+ if declared is None:
249
+ return frozenset()
250
+ try:
251
+ names = json.loads(declared)
252
+ except json.JSONDecodeError:
253
+ return frozenset()
254
+ if not isinstance(names, list) or not all(isinstance(n, str) and n for n in names):
255
+ return frozenset()
256
+ return frozenset(names)
257
+
258
+
214
259
  def root_property(document: dict[str, Any], name: str) -> str | None:
215
260
  """One `metadata.component` property's value, or `None`.
216
261
 
@@ -13,7 +13,7 @@ would invent the evidence a later stage would then act on.
13
13
  from __future__ import annotations
14
14
 
15
15
  from dataclasses import dataclass
16
- from typing import Literal
16
+ from typing import Literal, Protocol
17
17
 
18
18
  from stacktrace_cli.correlate.composition import Component, Composition
19
19
  from stacktrace_cli.sessions.protocols import ToolCallLike
@@ -58,118 +58,24 @@ unresolvable by construction rather than by omission. Collapsing them reads as
58
58
  """
59
59
  EdgeType = Literal["observed_invocation"]
60
60
 
61
- #: Built-in tool names per agent kind. The agent itself is the actor for these,
62
- #: so they resolve to the agent root rather than to any component.
63
- #:
64
- #: Closed and per-kind on purpose: a kind with no known set returns False for
65
- #: every name, so a new kind starts honestly unresolved rather than silently
66
- #: promoted. The cost of the closed list is that it drifts as an agent adds
67
- #: tools — measured over 200 sessions, an earlier ten-name list left 489 calls
68
- #: reading `unresolved` when the agent itself had plainly made them, which is a
69
- #: worse answer than saying so. Every name below was observed in a real
70
- #: transcript; a name that stops appearing is left in place, since an old
71
- #: session still names it.
72
- _BUILTIN_TOOLS: dict[str, frozenset[str]] = {
73
- "claude-code": frozenset(
74
- {
75
- # File and shell
76
- "Read",
77
- "Write",
78
- "Edit",
79
- "Bash",
80
- "Glob",
81
- "Grep",
82
- "NotebookEdit",
83
- # Retrieval
84
- "WebFetch",
85
- "WebSearch",
86
- "ToolSearch",
87
- # Session and planning surface
88
- "TodoWrite",
89
- "ExitPlanMode",
90
- "EnterPlanMode",
91
- "AskUserQuestion",
92
- "Artifact",
93
- "ReportFindings",
94
- # Background work and messaging
95
- "Monitor",
96
- "TaskOutput",
97
- "TaskStop",
98
- "SendMessage",
99
- "SendUserFile",
100
- "ListAgents",
101
- }
102
- )
103
- }
104
-
105
- #: Subagent types an agent kind provides itself. `Agent` naming one of these is
106
- #: the agent dispatching its own generic worker, not an installed component, so
107
- #: it resolves to the agent root exactly as a built-in tool does. A name outside
108
- #: this set is a configured subagent and joins to an `agent` component.
109
- _BUILTIN_SUBAGENTS: dict[str, frozenset[str]] = {
110
- "claude-code": frozenset(
111
- {
112
- "general-purpose",
113
- "Explore",
114
- "Plan",
115
- "claude",
116
- # Shipped inside the executable rather than installed, so no scan of
117
- # the filesystem can see them; see `_BUILTIN_SKILLS`.
118
- "claude-code-guide",
119
- "statusline-setup",
120
- "output-style-setup",
121
- }
122
- )
123
- }
124
-
125
- #: Skills an agent kind ships *inside its own executable*. Not a scanning gap:
126
- #: these have no manifest, no directory and no package anywhere on the
127
- #: filesystem, so no composition analysis can find them however thorough it is.
128
- #: Extracted from the shipped binary rather than guessed — every name below
129
- #: appears there as skill frontmatter.
130
- #:
131
- #: Unlike built-in tools, these are consulted only *after* the composition has
132
- #: been asked and has no answer. The names are ordinary words — `plan`, `run`,
133
- #: `design`, `verify` — that a user's own skill may legitimately take, and
134
- #: checking them first would attribute an installed skill's call to the agent
135
- #: itself. Installed always wins; this is the fallback.
136
- _BUILTIN_SKILLS: dict[str, frozenset[str]] = {
137
- "claude-code": frozenset(
138
- {
139
- "api-design",
140
- "artifact-components",
141
- "artifact-dashboard",
142
- "artifact-data-table",
143
- "artifact-design",
144
- "artifact-diagramming",
145
- "artifact-explainer",
146
- "artifact-pr-review",
147
- "artifact-report",
148
- "code-reviewer",
149
- "coding-standards",
150
- "design",
151
- "design-sync",
152
- "doc",
153
- "example",
154
- "fix-lint",
155
- "import-to-claude-code",
156
- "meeting-notes",
157
- "plan",
158
- "plan-artifact",
159
- "prototype",
160
- "review-changes",
161
- "run",
162
- "run-skill-generator",
163
- "sheet",
164
- "slides",
165
- "ticket-analyzer",
166
- "verify",
167
- "whiteboard",
168
- "workshop",
169
- "workshop-page",
170
- }
171
- )
172
- }
61
+ #: Tool classes whose calls the agent itself makes. OpenAIDR classes every call
62
+ #: per kind (its ADR-0021), so this is the whole of "built-in" for any kind it
63
+ #: reads: a call it could not class is `None` and stays unrecognized, never
64
+ #: silently promoted. `delegate` and `skill` name a component, so a call in
65
+ #: either is resolved by what it names, and `mcp` by its server.
66
+ _AGENT_ACTS = frozenset(
67
+ {
68
+ "shell",
69
+ "shell_control",
70
+ "file_read",
71
+ "file_search",
72
+ "file_write",
73
+ "file_edit",
74
+ "web_fetch",
75
+ "web_search",
76
+ "other",
77
+ }
78
+ )
173
79
 
174
80
  #: Component types a composition may answer a key with, beyond the key's own.
175
81
  #:
@@ -181,12 +87,12 @@ _BUILTIN_SKILLS: dict[str, frozenset[str]] = {
181
87
  #: leaves a component that is plainly installed reading as absent.
182
88
  _EQUIVALENT_TYPES: dict[str, tuple[str, ...]] = {"skill": ("command",)}
183
89
 
184
- #: Tool name -> (component type, the argument naming the component). Verified
185
- #: against real Claude Code sessions: `Skill` carries `input.skill`, and subagent
186
- #: dispatch is `Agent` with `input.subagent_type`.
90
+ #: Tool class -> (component type, the canonical argument naming it). A call
91
+ #: that runs a skill names it under `skill`, and one that dispatches a subagent
92
+ #: names it under `agent`, whatever the kind records them as.
187
93
  _ARGUMENT_SIGNALS: dict[str, tuple[str, str]] = {
188
- "Skill": ("skill", "skill"),
189
- "Agent": ("agent", "subagent_type"),
94
+ "skill": ("skill", "skill"),
95
+ "delegate": ("agent", "agent"),
190
96
  }
191
97
 
192
98
 
@@ -220,25 +126,68 @@ class Resolution:
220
126
  target: str | None
221
127
 
222
128
 
223
- def is_builtin(tool_name: str, agent_kind: str) -> bool:
224
- """Whether this kind's own agent is the actor for this tool."""
225
- return tool_name in _BUILTIN_TOOLS.get(agent_kind, frozenset())
129
+ class NamedTool(Protocol):
130
+ """What deciding a built-in reads: a call, or a summary of one."""
226
131
 
132
+ @property
133
+ def tool_name(self) -> str: ...
134
+ @property
135
+ def mcp_server(self) -> str | None: ...
136
+ @property
137
+ def tool_class(self) -> str | None: ...
227
138
 
228
- def is_builtin_subagent(call: ToolCallLike, agent_kind: str) -> bool:
229
- """Whether this call dispatches a subagent the agent kind provides itself."""
230
- if call.tool_name != "Agent":
139
+
140
+ def is_builtin(call: NamedTool, agent_kind: str) -> bool:
141
+ """Whether this kind's own agent is the actor for this call.
142
+
143
+ An MCP server picks its own tool names, and nothing stops one from choosing
144
+ `Read` or `Bash`; `call.mcp_server` is the client's own record of where the
145
+ call went, so a call carrying one is never the agent's.
146
+ """
147
+ del agent_kind # The class is filled per kind upstream; nothing here varies by it.
148
+ if call.mcp_server:
231
149
  return False
232
- requested = (call.arguments or {}).get("subagent_type")
233
- return isinstance(requested, str) and requested in _BUILTIN_SUBAGENTS.get(
234
- agent_kind, frozenset()
150
+ return call.tool_class in _AGENT_ACTS
151
+
152
+
153
+ def _requested_subagent(call: ToolCallLike) -> str | None:
154
+ """The subagent a delegating call asked for, or None."""
155
+ if call.tool_class != "delegate":
156
+ return None
157
+ requested = call.canonical_arguments.get("agent")
158
+ return requested if isinstance(requested, str) and requested else None
159
+
160
+
161
+ def is_builtin_subagent(call: ToolCallLike, composition: Composition | None) -> bool:
162
+ """Whether this call dispatches a subagent type the agent provides itself.
163
+
164
+ Naming one is the agent dispatching its own generic worker, not an
165
+ installed component, so it resolves to the agent root exactly as a
166
+ built-in tool does. Asked of the composition, which lists what its kind
167
+ provides (OpenACA ADR-0076): a name outside that list is a configured
168
+ subagent and joins to an `agent` component.
169
+ """
170
+ requested = _requested_subagent(call)
171
+ return (
172
+ requested is not None
173
+ and composition is not None
174
+ and requested in composition.builtin_agents
235
175
  )
236
176
 
237
177
 
238
- def is_builtin_skill(key: JoinKey, agent_kind: str) -> bool:
239
- """Whether this key names a skill the agent kind ships inside its executable."""
240
- return key.component_type == "skill" and key.name in _BUILTIN_SKILLS.get(
241
- agent_kind, frozenset()
178
+ def is_builtin_skill(key: JoinKey, composition: Composition | None) -> bool:
179
+ """Whether this key names a skill the agent ships itself.
180
+
181
+ Consulted only *after* the composition's components have no answer. The
182
+ names are ordinary words — `run`, `design`, `verify` — that a user's own
183
+ skill may legitimately take, and checking them first would attribute an
184
+ installed skill's call to the agent itself. Installed always wins; this is
185
+ the fallback.
186
+ """
187
+ return (
188
+ key.component_type == "skill"
189
+ and composition is not None
190
+ and key.name in composition.builtin_skills
242
191
  )
243
192
 
244
193
 
@@ -256,7 +205,9 @@ def candidates_for(key: JoinKey, composition: Composition) -> tuple[Component, .
256
205
  return candidates
257
206
 
258
207
 
259
- def builtin_category_and_name(call: ToolCallLike, agent_kind: str) -> tuple[str, str] | None:
208
+ def builtin_category_and_name(
209
+ call: ToolCallLike, composition: Composition | None
210
+ ) -> tuple[str, str] | None:
260
211
  """What a built-in resolution was, re-asked rather than read.
261
212
 
262
213
  A built-in's `Resolution` is `key=None, candidates=(), target=root` and is
@@ -267,13 +218,12 @@ def builtin_category_and_name(call: ToolCallLike, agent_kind: str) -> tuple[str,
267
218
  attribution and a finding's own subject have to give the same answer, and
268
219
  two copies is how they would come to differ.
269
220
  """
270
- if is_builtin_subagent(call, agent_kind):
271
- requested = (call.arguments or {}).get("subagent_type")
272
- return "subagent", str(requested)
273
- if is_builtin(call.tool_name, agent_kind):
221
+ if is_builtin_subagent(call, composition):
222
+ return "subagent", str(_requested_subagent(call))
223
+ if is_builtin(call, composition.agent_kind if composition is not None else ""):
274
224
  return "tool", call.tool_name
275
225
  key = join_key(call)
276
- if key is not None and is_builtin_skill(key, agent_kind):
226
+ if key is not None and is_builtin_skill(key, composition):
277
227
  return "skill", key.name
278
228
  return None
279
229
 
@@ -295,6 +245,14 @@ def join_key(call: ToolCallLike) -> JoinKey | None:
295
245
  return JoinKey(component_type, name if call.mcp_server else _unqualified(name))
296
246
 
297
247
 
248
+ def listed_key(component_type: str, name: str) -> JoinKey:
249
+ """The key a component the agent's own context lists by name is looked up
250
+ by: as a call naming it would be (`join_key`), a skill's plugin qualifier
251
+ dropped and a server's name kept as the client recorded it. Ask it with
252
+ `candidates_for`, so a skill inventoried as a command still answers."""
253
+ return JoinKey(component_type, name if component_type == "mcp_server" else _unqualified(name))
254
+
255
+
298
256
  def addressed_naming(call: ToolCallLike) -> tuple[str, str] | None:
299
257
  """The component type this call named, and the word it named it by.
300
258
 
@@ -311,11 +269,11 @@ def addressed_naming(call: ToolCallLike) -> tuple[str, str] | None:
311
269
  """
312
270
  if call.mcp_server:
313
271
  return "mcp_server", call.mcp_server
314
- signal = _ARGUMENT_SIGNALS.get(call.tool_name)
272
+ signal = _ARGUMENT_SIGNALS.get(call.tool_class or "")
315
273
  if signal is None:
316
274
  return None
317
275
  component_type, argument = signal
318
- value = (call.arguments or {}).get(argument)
276
+ value = call.canonical_arguments.get(argument)
319
277
  if not isinstance(value, str) or not value:
320
278
  return None
321
279
  return component_type, value
@@ -342,13 +300,10 @@ def _unqualified(name: str) -> str:
342
300
 
343
301
  def resolve(call: ToolCallLike, composition: Composition) -> Resolution:
344
302
  """Resolve one call against one composition."""
345
- # An MCP server picks its own tool names, and nothing stops one from
346
- # choosing `Read` or `Bash`. `call.mcp_server` is the client's own record of
347
- # where the call actually went, so a call carrying one is never read as a
348
- # built-in: the server key, not the borrowed name, decides what this named.
303
+ # A call carrying a server is never read as a built-in (`is_builtin`): the
304
+ # server key, not a borrowed tool name, decides what it named.
349
305
  if not call.mcp_server and (
350
- is_builtin(call.tool_name, composition.agent_kind)
351
- or is_builtin_subagent(call, composition.agent_kind)
306
+ is_builtin(call, composition.agent_kind) or is_builtin_subagent(call, composition)
352
307
  ):
353
308
  # The agent is the actor. Naming a component here would attribute the
354
309
  # call to something that did not make it.
@@ -364,7 +319,7 @@ def resolve(call: ToolCallLike, composition: Composition) -> Resolution:
364
319
 
365
320
  key = join_key(call)
366
321
  if key is None:
367
- recognized = bool(call.mcp_server) or call.tool_name in _ARGUMENT_SIGNALS
322
+ recognized = bool(call.mcp_server) or call.tool_class in _ARGUMENT_SIGNALS
368
323
  return _unresolved(
369
324
  call.span,
370
325
  None,
@@ -373,7 +328,7 @@ def resolve(call: ToolCallLike, composition: Composition) -> Resolution:
373
328
 
374
329
  candidates = candidates_for(key, composition)
375
330
  if len(candidates) == 0:
376
- if is_builtin_skill(key, composition.agent_kind):
331
+ if is_builtin_skill(key, composition):
377
332
  # Asked after the composition, never before: an installed skill of
378
333
  # the same name is the real target, and this is only what remains.
379
334
  # `key` is dropped exactly as it is for a built-in tool: the agent
@@ -52,21 +52,19 @@ CAPABILITY_NAMES = frozenset(
52
52
  #: claim about intent.
53
53
  NEVER_OBSERVED = frozenset({"credential_access", "sensitive_data_access"})
54
54
 
55
- #: A tool whose identity *is* the capability. No interpretation: `WebFetch`
56
- #: reaches the network because that is what the tool is. Covers 95.6% of calls
57
- #: on a measured corpus — 23,835 of 24,934 — which is why every other mechanism
58
- #: below is a refinement rather than the mechanism.
59
- _TOOL_CAPABILITY: dict[str, str] = {
60
- "Bash": "shell_exec",
61
- "Read": "file_read",
62
- "Grep": "file_read",
63
- "Glob": "file_read",
64
- "NotebookRead": "file_read",
65
- "Write": "file_write",
66
- "Edit": "file_write",
67
- "NotebookEdit": "file_write",
68
- "WebFetch": "network_egress",
69
- "WebSearch": "network_egress",
55
+ #: A tool whose identity *is* the capability, by the class OpenAIDR gives it
56
+ #: for every kind. No interpretation: a web fetch reaches the network because
57
+ #: that is what the tool is. Covers 95.6% of calls on a measured corpus --
58
+ #: 23,835 of 24,934 -- which is why every other mechanism below is a refinement
59
+ #: rather than the mechanism.
60
+ _CLASS_CAPABILITY: dict[str, str] = {
61
+ "shell": "shell_exec",
62
+ "file_read": "file_read",
63
+ "file_search": "file_read",
64
+ "file_write": "file_write",
65
+ "file_edit": "file_write",
66
+ "web_fetch": "network_egress",
67
+ "web_search": "network_egress",
70
68
  }
71
69
 
72
70
  #: Head binaries that say more about a shell call than `shell_exec` alone.
@@ -987,15 +985,16 @@ def observe(call: ToolCallLike) -> tuple[Observation, ...]:
987
985
  # claimed by the server, is the only capability this function may report
988
986
  # for one.
989
987
  if not call.mcp_server:
990
- capability = _TOOL_CAPABILITY.get(call.tool_name)
988
+ capability = _CLASS_CAPABILITY.get(call.tool_class or "")
991
989
  if capability:
992
990
  found[capability] = Observation(
993
991
  capability=capability,
994
992
  confidence="high",
995
993
  evidence={"kind": "tool_name", "value": call.tool_name},
996
994
  )
997
- if call.tool_name == "Bash":
998
- for binary, arguments in command_segments((call.arguments or {}).get("command")):
995
+ if call.tool_class == "shell":
996
+ command = call.canonical_arguments.get("command")
997
+ for binary, arguments in command_segments(command):
999
998
  for name, cited in _segment_capabilities(binary, arguments):
1000
999
  # `shell_exec` is already recorded at high confidence from the
1001
1000
  # tool itself; a parsed binary must never downgrade it.
@@ -1016,7 +1015,7 @@ def observe(call: ToolCallLike) -> tuple[Observation, ...]:
1016
1015
  # Registering the redirect first would relabel those writes as
1017
1016
  # redirect-derived and narrow a match to a target that is not theirs.
1018
1017
  if "file_write" not in found:
1019
- for target in redirect_targets((call.arguments or {}).get("command")):
1018
+ for target in redirect_targets(command):
1020
1019
  found["file_write"] = Observation(
1021
1020
  capability="file_write",
1022
1021
  confidence="medium",