tracerazor 1.1.0__py3-none-win_amd64.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 (128) hide show
  1. tracerazor/__init__.py +143 -0
  2. tracerazor/_adapters.py +193 -0
  3. tracerazor/_adaptive_k.py +268 -0
  4. tracerazor/_audit_client.py +389 -0
  5. tracerazor/_audit_trace.py +73 -0
  6. tracerazor/_audit_tracer.py +187 -0
  7. tracerazor/_canonicalize.py +142 -0
  8. tracerazor/_consensus.py +105 -0
  9. tracerazor/_launcher.py +120 -0
  10. tracerazor/_mutation.py +99 -0
  11. tracerazor/_naive_ensemble.py +126 -0
  12. tracerazor/_report.py +107 -0
  13. tracerazor/_self_consistency.py +150 -0
  14. tracerazor/_trice/__init__.py +113 -0
  15. tracerazor/_trice/adapters.py +420 -0
  16. tracerazor/_trice/artifact.py +577 -0
  17. tracerazor/_trice/bundle.py +289 -0
  18. tracerazor/_trice/claim.py +405 -0
  19. tracerazor/_trice/contract.py +499 -0
  20. tracerazor/_trice/crates.py +639 -0
  21. tracerazor/_trice/design.py +475 -0
  22. tracerazor/_trice/doctor.py +349 -0
  23. tracerazor/_trice/evidence.py +199 -0
  24. tracerazor/_trice/examples/trice_adapter_profile_bundled_tasks.json +13 -0
  25. tracerazor/_trice/examples/trice_adapter_profile_echo.json +13 -0
  26. tracerazor/_trice/examples/trice_patch_fix_offbyone.json +12 -0
  27. tracerazor/_trice/examples/trice_remote_smoke_patch.json +17 -0
  28. tracerazor/_trice/examples/trice_remote_smoke_source.json +33 -0
  29. tracerazor/_trice/examples/trice_remote_smoke_suite.json +36 -0
  30. tracerazor/_trice/examples/trice_repair_bundled_tasks.py +128 -0
  31. tracerazor/_trice/examples/trice_suite_bundled_live.json +52 -0
  32. tracerazor/_trice/examples/trice_suite_fix_offbyone.json +17 -0
  33. tracerazor/_trice/install.py +616 -0
  34. tracerazor/_trice/integrity.py +588 -0
  35. tracerazor/_trice/learn.py +53 -0
  36. tracerazor/_trice/live.py +901 -0
  37. tracerazor/_trice/paper.py +1103 -0
  38. tracerazor/_trice/policy.py +131 -0
  39. tracerazor/_trice/protocol.py +495 -0
  40. tracerazor/_trice/provenance.py +80 -0
  41. tracerazor/_trice/readiness.py +512 -0
  42. tracerazor/_trice/recall.py +95 -0
  43. tracerazor/_trice/receipt.py +167 -0
  44. tracerazor/_trice/release.py +518 -0
  45. tracerazor/_trice/release_evidence.py +694 -0
  46. tracerazor/_trice/render.py +70 -0
  47. tracerazor/_trice/replay.py +64 -0
  48. tracerazor/_trice/report.py +52 -0
  49. tracerazor/_trice/reproduction.py +427 -0
  50. tracerazor/_trice/research.py +532 -0
  51. tracerazor/_trice/schemas/trice_adapter_profile.schema.json +84 -0
  52. tracerazor/_trice/schemas/trice_artifact_card.schema.json +81 -0
  53. tracerazor/_trice/schemas/trice_bundle_manifest.schema.json +51 -0
  54. tracerazor/_trice/schemas/trice_claim_card.schema.json +82 -0
  55. tracerazor/_trice/schemas/trice_contract_card.schema.json +92 -0
  56. tracerazor/_trice/schemas/trice_crates_card.schema.json +144 -0
  57. tracerazor/_trice/schemas/trice_design_card.schema.json +108 -0
  58. tracerazor/_trice/schemas/trice_evidence_manifest.schema.json +65 -0
  59. tracerazor/_trice/schemas/trice_install_card.schema.json +85 -0
  60. tracerazor/_trice/schemas/trice_integrity_card.schema.json +85 -0
  61. tracerazor/_trice/schemas/trice_patch_spec.schema.json +70 -0
  62. tracerazor/_trice/schemas/trice_protocol_lock.schema.json +121 -0
  63. tracerazor/_trice/schemas/trice_release_card.schema.json +86 -0
  64. tracerazor/_trice/schemas/trice_release_evidence.schema.json +97 -0
  65. tracerazor/_trice/schemas/trice_reproduction_card.schema.json +82 -0
  66. tracerazor/_trice/schemas/trice_research_card.schema.json +124 -0
  67. tracerazor/_trice/schemas/trice_run_receipt.schema.json +258 -0
  68. tracerazor/_trice/schemas/trice_suite_manifest.schema.json +208 -0
  69. tracerazor/_trice/schemas/trice_suite_readiness.schema.json +97 -0
  70. tracerazor/_trice/schemas.py +206 -0
  71. tracerazor/_trice/score.py +130 -0
  72. tracerazor/_trice/segment.py +230 -0
  73. tracerazor/_trice/skills/tracerazor/SKILL.md +123 -0
  74. tracerazor/_trice/stats.py +223 -0
  75. tracerazor/_trice/suite.py +917 -0
  76. tracerazor/_trice/user.py +118 -0
  77. tracerazor/agent_assets/extensions/claude-code/tracerazor/.claude-plugin/plugin.json +12 -0
  78. tracerazor/agent_assets/extensions/claude-code/tracerazor/.mcp.json +8 -0
  79. tracerazor/agent_assets/extensions/claude-code/tracerazor/README.md +15 -0
  80. tracerazor/agent_assets/extensions/claude-code/tracerazor/hooks/hooks.json +52 -0
  81. tracerazor/agent_assets/extensions/claude-code/tracerazor/skills/tracerazor/SKILL.md +123 -0
  82. tracerazor/agent_assets/extensions/gemini-cli/tracerazor/README.md +13 -0
  83. tracerazor/agent_assets/extensions/gemini-cli/tracerazor/gemini-extension.json +11 -0
  84. tracerazor/agent_assets/extensions/gemini-cli/tracerazor/hooks/hooks.json +56 -0
  85. tracerazor/agent_assets/extensions/gemini-cli/tracerazor/skills/tracerazor/SKILL.md +123 -0
  86. tracerazor/agent_assets/plugins/tracerazor/.codex-plugin/plugin.json +29 -0
  87. tracerazor/agent_assets/plugins/tracerazor/.mcp.json +6 -0
  88. tracerazor/agent_assets/plugins/tracerazor/hooks/hooks.json +52 -0
  89. tracerazor/agent_assets/plugins/tracerazor/skills/tracerazor/SKILL.md +123 -0
  90. tracerazor/agent_assets/plugins/tracerazor/skills/tracerazor/agents/openai.yaml +7 -0
  91. tracerazor/agent_assets/skills/tracerazor/SKILL.md +123 -0
  92. tracerazor/agent_assets/tracerazor.toml +16 -0
  93. tracerazor/agent_assets/traces/support-agent-run-2847.json +117 -0
  94. tracerazor/bin/.gitkeep +0 -0
  95. tracerazor/bin/tracerazor.exe +0 -0
  96. tracerazor/errors.py +23 -0
  97. tracerazor/integrations/__init__.py +9 -0
  98. tracerazor/integrations/crewai/__init__.py +15 -0
  99. tracerazor/integrations/crewai/callback.py +281 -0
  100. tracerazor/integrations/langgraph/__init__.py +16 -0
  101. tracerazor/integrations/langgraph/callback.py +179 -0
  102. tracerazor/integrations/langgraph/trace_builder.py +170 -0
  103. tracerazor/integrations/openai_agents/__init__.py +15 -0
  104. tracerazor/integrations/openai_agents/hooks.py +252 -0
  105. tracerazor/mcp_server.py +1620 -0
  106. tracerazor/redundancy/__init__.py +7 -0
  107. tracerazor/redundancy/evaluate_full.py +781 -0
  108. tracerazor/redundancy/generate_data.py +252 -0
  109. tracerazor/redundancy/substitutability.py +587 -0
  110. tracerazor/runtime/__init__.py +100 -0
  111. tracerazor/runtime/compiler.py +160 -0
  112. tracerazor/runtime/guardrails.py +660 -0
  113. tracerazor/runtime/instrumentation.py +1264 -0
  114. tracerazor/runtime/models.py +770 -0
  115. tracerazor/runtime/persistence.py +697 -0
  116. tracerazor/runtime/policy.py +217 -0
  117. tracerazor/runtime/processor.py +862 -0
  118. tracerazor/schemas/report.schema.json +239 -0
  119. tracerazor/schemas/trace.schema.json +64 -0
  120. tracerazor/schemas/tracerazor_event.schema.json +175 -0
  121. tracerazor/trice/__init__.py +188 -0
  122. tracerazor/trice/__main__.py +5 -0
  123. tracerazor/trice/cli.py +392 -0
  124. tracerazor-1.1.0.dist-info/METADATA +1756 -0
  125. tracerazor-1.1.0.dist-info/RECORD +128 -0
  126. tracerazor-1.1.0.dist-info/WHEEL +5 -0
  127. tracerazor-1.1.0.dist-info/entry_points.txt +4 -0
  128. tracerazor-1.1.0.dist-info/licenses/LICENSE +21 -0
tracerazor/__init__.py ADDED
@@ -0,0 +1,143 @@
1
+ """
2
+ TraceRazor 1.1.0
3
+
4
+ TraceRazor is a local-first efficiency supervisor for production AI agents.
5
+ Its stable core audits runs, diagnoses structural token waste, and verifies
6
+ before/after improvements. Adaptive sampling remains an independent Labs API.
7
+
8
+ 1. Audit: record your agent's reasoning steps and tool calls, then submit
9
+ the trace to the TraceRazor analyzer to get a Token Alignment Score (TAS),
10
+ per-metric breakdowns, fix recommendations, and savings estimates.
11
+
12
+ 2. Sample (Labs): TraceRazor's AdaptiveKNode can sample K candidates per step
13
+ and select a consensus result. This experimental surface has no general
14
+ efficacy claim and is not part of the agent-supervisor GA contract.
15
+
16
+ The stable product is the auditor and agent-efficiency workflow. Sampling is
17
+ kept import-compatible as a separate Labs API.
18
+
19
+
20
+ AUDIT QUICKSTART
21
+ ----------------
22
+ Record steps manually with Tracer:
23
+
24
+ from tracerazor import Tracer
25
+
26
+ with Tracer(agent_name="support-agent", framework="openai") as t:
27
+ response = llm.invoke(prompt)
28
+ t.reasoning(response.text, tokens=response.usage.total_tokens)
29
+
30
+ result = lookup_order(order_id="ORD-123")
31
+ t.tool("lookup_order", params={"order_id": "ORD-123"},
32
+ output=str(result), success=True, tokens=80)
33
+
34
+ report = t.analyse()
35
+ print(report.summary())
36
+ # Prefer a same-workload `tracerazor compare` gate in CI.
37
+
38
+
39
+ SAMPLING QUICKSTART
40
+ -------------------
41
+ Drop AdaptiveKNode into a LangGraph graph as a replacement for your ReAct node:
42
+
43
+ from tracerazor import AdaptiveKNode, openai_llm
44
+ from openai import AsyncOpenAI
45
+
46
+ llm = openai_llm(AsyncOpenAI(), model="gpt-4.1")
47
+ node = AdaptiveKNode(llm=llm, tools=my_tools, k_max=5)
48
+
49
+ graph = StateGraph(AgentState)
50
+ graph.add_node("agent", node)
51
+
52
+ After the graph runs, the consensus report is available at:
53
+ result["consensus_report"].summary()
54
+
55
+
56
+ AUDIT API
57
+ ---------
58
+ Tracer Record steps and submit for analysis.
59
+ TraceRazorClient Lower-level client for submitting trace dicts directly.
60
+ TraceRazorReport Parsed audit result with TAS score, metrics, and fixes.
61
+ TraceStep Data class for a single recorded step.
62
+
63
+ SAMPLING API (LABS)
64
+ -------------------
65
+ AdaptiveKNode LangGraph node. Samples K candidates per step and picks
66
+ the consensus winner. K adapts dynamically.
67
+ ExactMatchConsensus Aggregates K branch proposals by exact-match comparison.
68
+ MutationMetadata Classifies tools as mutating vs read-only. Used by
69
+ AdaptiveKNode to reset K after state-changing calls.
70
+ NaiveKEnsemble Baseline: runs K independent full-task agents and picks
71
+ the majority result.
72
+ SelfConsistencyBaseline
73
+ Baseline: deterministic tool calls, then re-samples the
74
+ final response K times and picks the best candidate.
75
+
76
+ LLM ADAPTERS
77
+ ------------
78
+ openai_llm Create an async LLM adapter from an AsyncOpenAI client.
79
+ anthropic_llm Create an async LLM adapter from an AsyncAnthropic client.
80
+ mock_llm Deterministic mock for tests and offline demos.
81
+
82
+ DATA TYPES
83
+ ----------
84
+ BranchProposal One candidate response from a single LLM sample.
85
+ ConsensusResult Output of ExactMatchConsensus.aggregate().
86
+ ConsensusReport Full trajectory record with consensus rates and token counts.
87
+ Outcome Enum: FULL_CONSENSUS, MAJORITY, DIVERGENT.
88
+ StepRecord Per-step detail inside a ConsensusReport.
89
+ TokenCounts Input, output, cached, and fresh token breakdown.
90
+ NaiveRunResult Result of one agent run inside NaiveKEnsemble.
91
+ SCResult Result of a SelfConsistencyBaseline run.
92
+ """
93
+
94
+ # Audit
95
+ from ._audit_tracer import Tracer
96
+ from ._audit_client import TraceRazorClient, TraceRazorReport
97
+ from ._audit_trace import TraceStep
98
+ from .errors import AuditError, BelowMinStepsError, BinaryNotFoundError, TraceRazorError, VerificationError
99
+
100
+ # Sampling
101
+ from ._adaptive_k import AdaptiveKNode
102
+ from ._consensus import BranchProposal, ConsensusResult, ExactMatchConsensus, Outcome
103
+ from ._mutation import MutationMetadata
104
+ from ._naive_ensemble import NaiveKEnsemble, NaiveRunResult
105
+ from ._report import ConsensusReport, StepRecord, TokenCounts
106
+ from ._self_consistency import SCResult, SelfConsistencyBaseline
107
+
108
+ # LLM adapters
109
+ from ._adapters import anthropic_llm, mock_llm, openai_llm
110
+
111
+ __version__ = "1.1.0"
112
+ __author__ = "Zulfaqar Hafez"
113
+
114
+ __all__ = [
115
+ # Audit
116
+ "Tracer",
117
+ "TraceRazorClient",
118
+ "TraceRazorReport",
119
+ "TraceStep",
120
+ "TraceRazorError",
121
+ "BinaryNotFoundError",
122
+ "AuditError",
123
+ "BelowMinStepsError",
124
+ "VerificationError",
125
+ # Sampling
126
+ "AdaptiveKNode",
127
+ "ExactMatchConsensus",
128
+ "ConsensusReport",
129
+ "MutationMetadata",
130
+ "NaiveKEnsemble",
131
+ "NaiveRunResult",
132
+ "SelfConsistencyBaseline",
133
+ "SCResult",
134
+ "BranchProposal",
135
+ "ConsensusResult",
136
+ "Outcome",
137
+ "StepRecord",
138
+ "TokenCounts",
139
+ # LLM adapters
140
+ "openai_llm",
141
+ "anthropic_llm",
142
+ "mock_llm",
143
+ ]
@@ -0,0 +1,193 @@
1
+ """LLM adapter factories for OpenAI and Anthropic clients.
2
+
3
+ Usage
4
+ -----
5
+ from tracerazor._adapters import openai_llm, anthropic_llm
6
+ from openai import AsyncOpenAI
7
+
8
+ llm = openai_llm(AsyncOpenAI(), model="gpt-4.1")
9
+ node = AdaptiveKNode(llm=llm, tools=my_tools)
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from typing import Any, Callable, Dict, List, Optional
14
+
15
+
16
+ # ── OpenAI ─────────────────────────────────────────────────────────────────
17
+
18
+
19
+ def openai_llm(
20
+ client: Any,
21
+ model: str = "gpt-4.1",
22
+ temperature: float = 1.0,
23
+ **kwargs: Any,
24
+ ) -> Callable:
25
+ """Return an async LLM callable backed by an AsyncOpenAI client.
26
+
27
+ The callable signature is ``(messages, tool_schema) -> dict`` as expected
28
+ by AdaptiveKNode.
29
+
30
+ Parameters
31
+ ----------
32
+ client:
33
+ An ``openai.AsyncOpenAI`` instance.
34
+ model:
35
+ Model ID. Default is gpt-4.1 (tau2-bench reference model).
36
+ temperature:
37
+ Sampling temperature. Use > 0 for branch diversity.
38
+ """
39
+
40
+ async def _call(messages: List[dict], tool_schema: List[dict]) -> dict:
41
+ tools_param = (
42
+ [
43
+ {
44
+ "type": "function",
45
+ "function": {
46
+ "name": t["name"],
47
+ "description": t.get("description", ""),
48
+ "parameters": t.get("parameters", {"type": "object", "properties": {}}),
49
+ },
50
+ }
51
+ for t in tool_schema
52
+ ]
53
+ if tool_schema
54
+ else None
55
+ )
56
+ response = await client.chat.completions.create(
57
+ model=model,
58
+ messages=messages,
59
+ tools=tools_param,
60
+ temperature=temperature,
61
+ **kwargs,
62
+ )
63
+ msg = response.choices[0].message
64
+ usage = response.usage
65
+
66
+ result: dict = {}
67
+ if msg.tool_calls:
68
+ tc = msg.tool_calls[0]
69
+ import json as _json
70
+ result["tool_name"] = tc.function.name
71
+ try:
72
+ result["arguments"] = _json.loads(tc.function.arguments)
73
+ except Exception:
74
+ result["arguments"] = {"raw": tc.function.arguments}
75
+ else:
76
+ result["final_answer"] = msg.content or ""
77
+
78
+ result["input_tokens"] = getattr(usage, "prompt_tokens", 0)
79
+ result["output_tokens"] = getattr(usage, "completion_tokens", 0)
80
+ # OpenAI prompt_tokens_details contains cached_tokens
81
+ details = getattr(usage, "prompt_tokens_details", None)
82
+ result["cached_tokens"] = getattr(details, "cached_tokens", 0) if details else 0
83
+
84
+ return result
85
+
86
+ return _call
87
+
88
+
89
+ # ── Anthropic ──────────────────────────────────────────────────────────────
90
+
91
+
92
+ def anthropic_llm(
93
+ client: Any,
94
+ model: str = "claude-sonnet-4-6",
95
+ temperature: float = 1.0,
96
+ **kwargs: Any,
97
+ ) -> Callable:
98
+ """Return an async LLM callable backed by an AsyncAnthropic client.
99
+
100
+ Parameters
101
+ ----------
102
+ client:
103
+ An ``anthropic.AsyncAnthropic`` instance.
104
+ model:
105
+ Model ID. Default is claude-sonnet-4-6.
106
+ temperature:
107
+ Sampling temperature.
108
+ """
109
+
110
+ async def _call(messages: List[dict], tool_schema: List[dict]) -> dict:
111
+ # Separate system messages (Anthropic takes system as a top-level param)
112
+ system_msgs = [m for m in messages if m.get("role") == "system"]
113
+ other_msgs = [m for m in messages if m.get("role") != "system"]
114
+ system_text = " ".join(m.get("content", "") for m in system_msgs) or None
115
+
116
+ tools_param = (
117
+ [
118
+ {
119
+ "name": t["name"],
120
+ "description": t.get("description", ""),
121
+ "input_schema": t.get("parameters", {"type": "object", "properties": {}}),
122
+ }
123
+ for t in tool_schema
124
+ ]
125
+ if tool_schema
126
+ else []
127
+ )
128
+
129
+ create_kwargs: dict = dict(
130
+ model=model,
131
+ messages=other_msgs,
132
+ max_tokens=4096,
133
+ temperature=temperature,
134
+ **kwargs,
135
+ )
136
+ if system_text:
137
+ create_kwargs["system"] = system_text
138
+ if tools_param:
139
+ create_kwargs["tools"] = tools_param
140
+
141
+ response = await client.messages.create(**create_kwargs)
142
+ usage = response.usage
143
+
144
+ result: dict = {}
145
+ for block in response.content:
146
+ if block.type == "tool_use":
147
+ result["tool_name"] = block.name
148
+ result["arguments"] = block.input or {}
149
+ break
150
+ else:
151
+ texts = [b.text for b in response.content if hasattr(b, "text")]
152
+ result["final_answer"] = " ".join(texts)
153
+
154
+ result["input_tokens"] = getattr(usage, "input_tokens", 0)
155
+ result["output_tokens"] = getattr(usage, "output_tokens", 0)
156
+ result["cached_tokens"] = getattr(usage, "cache_read_input_tokens", 0)
157
+
158
+ return result
159
+
160
+ return _call
161
+
162
+
163
+ # ── Generic / test helper ──────────────────────────────────────────────────
164
+
165
+
166
+ def mock_llm(responses: list) -> Callable:
167
+ """Deterministic mock LLM for tests and offline demos.
168
+
169
+ Returns the *same* response to all K branches at a given step (branches
170
+ share identical message histories), then advances to the next response when
171
+ the message history grows (indicating a new step has started).
172
+
173
+ Each item in ``responses`` is a dict as returned by the real adapters::
174
+
175
+ llm = mock_llm([
176
+ {"tool_name": "search", "arguments": {"q": "hello"},
177
+ "input_tokens": 100, "output_tokens": 20},
178
+ {"final_answer": "The answer is 42",
179
+ "input_tokens": 120, "output_tokens": 10},
180
+ ])
181
+ """
182
+ _state = {"idx": 0, "last_msg_len": -1}
183
+
184
+ async def _call(messages: List[dict], tool_schema: List[dict]) -> dict:
185
+ msg_len = len(messages)
186
+ # New step: message history grew → advance response pointer
187
+ if msg_len != _state["last_msg_len"]:
188
+ if _state["last_msg_len"] >= 0:
189
+ _state["idx"] = (_state["idx"] + 1) % len(responses)
190
+ _state["last_msg_len"] = msg_len
191
+ return dict(responses[_state["idx"]])
192
+
193
+ return _call
@@ -0,0 +1,268 @@
1
+ """AdaptiveKNode: core adaptive-K speculative ensemble.
2
+
3
+ The node runs the agent loop with K parallel LLM samples per step.
4
+ Consensus collapses K → 1 for tool execution (preventing duplicate side effects
5
+ at mutation boundaries) then re-fans. K shrinks under sustained full consensus
6
+ and resets on divergence or after a mutating call.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ from collections import Counter
12
+ from typing import Any, Callable, Dict, List, Optional
13
+
14
+ from ._consensus import BranchProposal, ConsensusResult, ExactMatchConsensus, Outcome
15
+ from ._mutation import MutationMetadata
16
+ from ._report import ConsensusReport, StepRecord, TokenCounts
17
+
18
+
19
+ class AdaptiveKNode:
20
+ """Adaptive-K speculative ensemble as a LangGraph-compatible node.
21
+
22
+ Drop-in replacement for a ReAct-style agent node. Wrap the same LLM call
23
+ and tool schema; the node handles K-sampling, consensus, and mutation
24
+ boundary collapsing transparently.
25
+
26
+ Parameters
27
+ ----------
28
+ llm:
29
+ Async callable ``(messages, tool_schema) -> dict`` where the returned
30
+ dict must contain at least one of:
31
+ - ``"tool_name"`` + ``"arguments"`` (tool call proposal)
32
+ - ``"final_answer"`` (terminal step)
33
+ Optional token fields: ``"input_tokens"``, ``"output_tokens"``,
34
+ ``"cached_tokens"``.
35
+ tools:
36
+ ``{tool_name: async_callable}`` mapping. Each callable receives the
37
+ tool arguments as keyword arguments and returns a string observation.
38
+ k_max:
39
+ Maximum parallel branches per step. Default 5 (matches the K=5 naive
40
+ ensemble baseline in the benchmark).
41
+ k_min:
42
+ Floor for K under sustained full consensus. Default 2.
43
+ consensus:
44
+ Aggregator instance. Defaults to ``ExactMatchConsensus()``.
45
+ mutation_metadata:
46
+ ``MutationMetadata`` instance or ``{tool_name: bool}`` override dict.
47
+ Unknown tools are treated as mutating (safe default).
48
+ max_steps:
49
+ Hard ceiling on agent loop iterations. Default 20.
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ llm: Callable,
55
+ tools: Dict[str, Callable],
56
+ *,
57
+ k_max: int = 5,
58
+ k_min: int = 2,
59
+ consensus: Optional[ExactMatchConsensus] = None,
60
+ mutation_metadata: Optional[Any] = None,
61
+ max_steps: int = 20,
62
+ ) -> None:
63
+ if k_min < 1:
64
+ raise ValueError("k_min must be >= 1")
65
+ # clamp k_min to k_max so callers can set k_max=1 without pain
66
+ k_min = min(k_min, k_max)
67
+
68
+ self.llm = llm
69
+ self.tools = tools
70
+ self.k_max = k_max
71
+ self.k_min = k_min
72
+ self.consensus = consensus or ExactMatchConsensus()
73
+ self.max_steps = max_steps
74
+
75
+ if isinstance(mutation_metadata, dict):
76
+ mutation_metadata = MutationMetadata(overrides=mutation_metadata)
77
+ self.mutation: MutationMetadata = mutation_metadata or MutationMetadata()
78
+
79
+ self._last_report: Optional[ConsensusReport] = None
80
+
81
+ # ── public ─────────────────────────────────────────────────────────────
82
+
83
+ @property
84
+ def report(self) -> Optional[ConsensusReport]:
85
+ """ConsensusReport from the most recent ``__call__`` invocation."""
86
+ return self._last_report
87
+
88
+ async def __call__(self, state: Dict[str, Any]) -> Dict[str, Any]:
89
+ """LangGraph node entry point.
90
+
91
+ Reads ``state["messages"]``, runs the adaptive-K agent loop, and
92
+ returns an updated state dict containing:
93
+ - ``"messages"``: final message list (shared history)
94
+ - ``"consensus_report"``: the ``ConsensusReport`` for this run
95
+ """
96
+ messages: List[dict] = list(state.get("messages", []))
97
+ tool_schema = self._build_tool_schema()
98
+
99
+ trajectory: List[dict] = []
100
+ step_records: List[StepRecord] = []
101
+ consensus_rates: List[float] = []
102
+ divergences: List[int] = []
103
+ total_tokens = TokenCounts()
104
+
105
+ k = self.k_max
106
+
107
+ for step in range(self.max_steps):
108
+ # ── 1. sample K completions in parallel ───────────────────────
109
+ proposals = await self._sample(messages, tool_schema, k)
110
+
111
+ # ── 2. aggregate consensus ────────────────────────────────────
112
+ result: ConsensusResult = self.consensus.aggregate(proposals)
113
+ winning = result.winning_proposal
114
+
115
+ # ── 3. token accounting ───────────────────────────────────────
116
+ step_tokens = TokenCounts(
117
+ input=sum(p.input_tokens for p in proposals),
118
+ output=sum(p.output_tokens for p in proposals),
119
+ cached=sum(p.cached_tokens for p in proposals),
120
+ )
121
+ total_tokens += step_tokens
122
+
123
+ consensus_rates.append(result.consensus_rate)
124
+ if result.outcome != Outcome.FULL:
125
+ divergences.append(step)
126
+
127
+ # ── 4. build divergent-alternatives record ────────────────────
128
+ key_counts: Counter[str] = Counter(p._key for p in proposals)
129
+ alts = [
130
+ {"tool": p.tool_name, "args": p.arguments, "answer": p.final_answer}
131
+ for p in proposals
132
+ if p._key != result.winning_key
133
+ and p.branch_id
134
+ == min(
135
+ (q.branch_id for q in proposals if q._key == p._key),
136
+ default=p.branch_id,
137
+ )
138
+ ]
139
+
140
+ # ── 5. adapt K for next step ──────────────────────────────────
141
+ if result.outcome == Outcome.FULL:
142
+ k = max(self.k_min, k - 1)
143
+ elif result.outcome == Outcome.DIVERGENT:
144
+ k = self.k_max
145
+
146
+ # ── 6a. final answer → break ──────────────────────────────────
147
+ if winning.tool_name is None:
148
+ answer = winning.final_answer or ""
149
+ trajectory.append({"type": "final_answer", "content": answer})
150
+ step_records.append(
151
+ StepRecord(
152
+ step_number=step,
153
+ outcome=result.outcome.value,
154
+ consensus_rate=result.consensus_rate,
155
+ k_used=result.k,
156
+ executed_tool=None,
157
+ executed_args=None,
158
+ final_answer=answer,
159
+ is_mutating=False,
160
+ tokens=step_tokens,
161
+ divergent_alternatives=alts,
162
+ )
163
+ )
164
+ messages.append({"role": "assistant", "content": answer})
165
+ break
166
+
167
+ # ── 6b. tool call ─────────────────────────────────────────────
168
+ tool_name = winning.tool_name
169
+ tool_args = winning.arguments or {}
170
+ is_mutating = self.mutation.is_mutating(tool_name)
171
+
172
+ observation = await self._execute_tool(tool_name, tool_args)
173
+
174
+ trajectory.append(
175
+ {
176
+ "type": "tool_call",
177
+ "tool": tool_name,
178
+ "args": tool_args,
179
+ "observation": observation,
180
+ "mutating": is_mutating,
181
+ "consensus_rate": result.consensus_rate,
182
+ }
183
+ )
184
+ step_records.append(
185
+ StepRecord(
186
+ step_number=step,
187
+ outcome=result.outcome.value,
188
+ consensus_rate=result.consensus_rate,
189
+ k_used=result.k,
190
+ executed_tool=tool_name,
191
+ executed_args=tool_args,
192
+ final_answer=None,
193
+ is_mutating=is_mutating,
194
+ tokens=step_tokens,
195
+ divergent_alternatives=alts,
196
+ )
197
+ )
198
+
199
+ # append call + observation to shared history
200
+ messages.append(
201
+ {
202
+ "role": "assistant",
203
+ "content": None,
204
+ "tool_calls": [{"name": tool_name, "arguments": tool_args}],
205
+ }
206
+ )
207
+ messages.append({"role": "tool", "name": tool_name, "content": observation})
208
+
209
+ # re-fan after mutation boundary
210
+ if is_mutating:
211
+ k = self.k_max
212
+
213
+ self._last_report = ConsensusReport(
214
+ trajectory=trajectory,
215
+ consensus_rate=consensus_rates,
216
+ divergences=divergences,
217
+ tokens=total_tokens,
218
+ steps=step_records,
219
+ )
220
+
221
+ return {**state, "messages": messages, "consensus_report": self._last_report}
222
+
223
+ # ── private helpers ─────────────────────────────────────────────────────
224
+
225
+ def _build_tool_schema(self) -> List[dict]:
226
+ return [
227
+ {
228
+ "name": name,
229
+ "description": getattr(fn, "__doc__", "") or "",
230
+ "parameters": getattr(fn, "_schema", {}),
231
+ }
232
+ for name, fn in self.tools.items()
233
+ ]
234
+
235
+ async def _sample(
236
+ self,
237
+ messages: List[dict],
238
+ tool_schema: List[dict],
239
+ k: int,
240
+ ) -> List[BranchProposal]:
241
+ """Call the LLM k times in parallel with identical inputs."""
242
+ tasks = [self.llm(list(messages), tool_schema) for _ in range(k)]
243
+ raw: list[dict] = await asyncio.gather(*tasks)
244
+ return [
245
+ BranchProposal(
246
+ branch_id=i,
247
+ tool_name=r.get("tool_name"),
248
+ arguments=r.get("arguments"),
249
+ final_answer=r.get("final_answer"),
250
+ input_tokens=r.get("input_tokens", 0),
251
+ output_tokens=r.get("output_tokens", 0),
252
+ cached_tokens=r.get("cached_tokens", 0),
253
+ )
254
+ for i, r in enumerate(raw)
255
+ ]
256
+
257
+ async def _execute_tool(self, tool_name: str, args: dict) -> str:
258
+ """Execute a registered tool once and return the observation string."""
259
+ fn = self.tools.get(tool_name)
260
+ if fn is None:
261
+ return f"[error: tool '{tool_name}' not registered]"
262
+ try:
263
+ result = fn(**args)
264
+ if asyncio.iscoroutine(result):
265
+ result = await result
266
+ return str(result)
267
+ except Exception as exc:
268
+ return f"[error: {exc}]"