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.
- tracerazor/__init__.py +143 -0
- tracerazor/_adapters.py +193 -0
- tracerazor/_adaptive_k.py +268 -0
- tracerazor/_audit_client.py +389 -0
- tracerazor/_audit_trace.py +73 -0
- tracerazor/_audit_tracer.py +187 -0
- tracerazor/_canonicalize.py +142 -0
- tracerazor/_consensus.py +105 -0
- tracerazor/_launcher.py +120 -0
- tracerazor/_mutation.py +99 -0
- tracerazor/_naive_ensemble.py +126 -0
- tracerazor/_report.py +107 -0
- tracerazor/_self_consistency.py +150 -0
- tracerazor/_trice/__init__.py +113 -0
- tracerazor/_trice/adapters.py +420 -0
- tracerazor/_trice/artifact.py +577 -0
- tracerazor/_trice/bundle.py +289 -0
- tracerazor/_trice/claim.py +405 -0
- tracerazor/_trice/contract.py +499 -0
- tracerazor/_trice/crates.py +639 -0
- tracerazor/_trice/design.py +475 -0
- tracerazor/_trice/doctor.py +349 -0
- tracerazor/_trice/evidence.py +199 -0
- tracerazor/_trice/examples/trice_adapter_profile_bundled_tasks.json +13 -0
- tracerazor/_trice/examples/trice_adapter_profile_echo.json +13 -0
- tracerazor/_trice/examples/trice_patch_fix_offbyone.json +12 -0
- tracerazor/_trice/examples/trice_remote_smoke_patch.json +17 -0
- tracerazor/_trice/examples/trice_remote_smoke_source.json +33 -0
- tracerazor/_trice/examples/trice_remote_smoke_suite.json +36 -0
- tracerazor/_trice/examples/trice_repair_bundled_tasks.py +128 -0
- tracerazor/_trice/examples/trice_suite_bundled_live.json +52 -0
- tracerazor/_trice/examples/trice_suite_fix_offbyone.json +17 -0
- tracerazor/_trice/install.py +616 -0
- tracerazor/_trice/integrity.py +588 -0
- tracerazor/_trice/learn.py +53 -0
- tracerazor/_trice/live.py +901 -0
- tracerazor/_trice/paper.py +1103 -0
- tracerazor/_trice/policy.py +131 -0
- tracerazor/_trice/protocol.py +495 -0
- tracerazor/_trice/provenance.py +80 -0
- tracerazor/_trice/readiness.py +512 -0
- tracerazor/_trice/recall.py +95 -0
- tracerazor/_trice/receipt.py +167 -0
- tracerazor/_trice/release.py +518 -0
- tracerazor/_trice/release_evidence.py +694 -0
- tracerazor/_trice/render.py +70 -0
- tracerazor/_trice/replay.py +64 -0
- tracerazor/_trice/report.py +52 -0
- tracerazor/_trice/reproduction.py +427 -0
- tracerazor/_trice/research.py +532 -0
- tracerazor/_trice/schemas/trice_adapter_profile.schema.json +84 -0
- tracerazor/_trice/schemas/trice_artifact_card.schema.json +81 -0
- tracerazor/_trice/schemas/trice_bundle_manifest.schema.json +51 -0
- tracerazor/_trice/schemas/trice_claim_card.schema.json +82 -0
- tracerazor/_trice/schemas/trice_contract_card.schema.json +92 -0
- tracerazor/_trice/schemas/trice_crates_card.schema.json +144 -0
- tracerazor/_trice/schemas/trice_design_card.schema.json +108 -0
- tracerazor/_trice/schemas/trice_evidence_manifest.schema.json +65 -0
- tracerazor/_trice/schemas/trice_install_card.schema.json +85 -0
- tracerazor/_trice/schemas/trice_integrity_card.schema.json +85 -0
- tracerazor/_trice/schemas/trice_patch_spec.schema.json +70 -0
- tracerazor/_trice/schemas/trice_protocol_lock.schema.json +121 -0
- tracerazor/_trice/schemas/trice_release_card.schema.json +86 -0
- tracerazor/_trice/schemas/trice_release_evidence.schema.json +97 -0
- tracerazor/_trice/schemas/trice_reproduction_card.schema.json +82 -0
- tracerazor/_trice/schemas/trice_research_card.schema.json +124 -0
- tracerazor/_trice/schemas/trice_run_receipt.schema.json +258 -0
- tracerazor/_trice/schemas/trice_suite_manifest.schema.json +208 -0
- tracerazor/_trice/schemas/trice_suite_readiness.schema.json +97 -0
- tracerazor/_trice/schemas.py +206 -0
- tracerazor/_trice/score.py +130 -0
- tracerazor/_trice/segment.py +230 -0
- tracerazor/_trice/skills/tracerazor/SKILL.md +123 -0
- tracerazor/_trice/stats.py +223 -0
- tracerazor/_trice/suite.py +917 -0
- tracerazor/_trice/user.py +118 -0
- tracerazor/agent_assets/extensions/claude-code/tracerazor/.claude-plugin/plugin.json +12 -0
- tracerazor/agent_assets/extensions/claude-code/tracerazor/.mcp.json +8 -0
- tracerazor/agent_assets/extensions/claude-code/tracerazor/README.md +15 -0
- tracerazor/agent_assets/extensions/claude-code/tracerazor/hooks/hooks.json +52 -0
- tracerazor/agent_assets/extensions/claude-code/tracerazor/skills/tracerazor/SKILL.md +123 -0
- tracerazor/agent_assets/extensions/gemini-cli/tracerazor/README.md +13 -0
- tracerazor/agent_assets/extensions/gemini-cli/tracerazor/gemini-extension.json +11 -0
- tracerazor/agent_assets/extensions/gemini-cli/tracerazor/hooks/hooks.json +56 -0
- tracerazor/agent_assets/extensions/gemini-cli/tracerazor/skills/tracerazor/SKILL.md +123 -0
- tracerazor/agent_assets/plugins/tracerazor/.codex-plugin/plugin.json +29 -0
- tracerazor/agent_assets/plugins/tracerazor/.mcp.json +6 -0
- tracerazor/agent_assets/plugins/tracerazor/hooks/hooks.json +52 -0
- tracerazor/agent_assets/plugins/tracerazor/skills/tracerazor/SKILL.md +123 -0
- tracerazor/agent_assets/plugins/tracerazor/skills/tracerazor/agents/openai.yaml +7 -0
- tracerazor/agent_assets/skills/tracerazor/SKILL.md +123 -0
- tracerazor/agent_assets/tracerazor.toml +16 -0
- tracerazor/agent_assets/traces/support-agent-run-2847.json +117 -0
- tracerazor/bin/.gitkeep +0 -0
- tracerazor/bin/tracerazor.exe +0 -0
- tracerazor/errors.py +23 -0
- tracerazor/integrations/__init__.py +9 -0
- tracerazor/integrations/crewai/__init__.py +15 -0
- tracerazor/integrations/crewai/callback.py +281 -0
- tracerazor/integrations/langgraph/__init__.py +16 -0
- tracerazor/integrations/langgraph/callback.py +179 -0
- tracerazor/integrations/langgraph/trace_builder.py +170 -0
- tracerazor/integrations/openai_agents/__init__.py +15 -0
- tracerazor/integrations/openai_agents/hooks.py +252 -0
- tracerazor/mcp_server.py +1620 -0
- tracerazor/redundancy/__init__.py +7 -0
- tracerazor/redundancy/evaluate_full.py +781 -0
- tracerazor/redundancy/generate_data.py +252 -0
- tracerazor/redundancy/substitutability.py +587 -0
- tracerazor/runtime/__init__.py +100 -0
- tracerazor/runtime/compiler.py +160 -0
- tracerazor/runtime/guardrails.py +660 -0
- tracerazor/runtime/instrumentation.py +1264 -0
- tracerazor/runtime/models.py +770 -0
- tracerazor/runtime/persistence.py +697 -0
- tracerazor/runtime/policy.py +217 -0
- tracerazor/runtime/processor.py +862 -0
- tracerazor/schemas/report.schema.json +239 -0
- tracerazor/schemas/trace.schema.json +64 -0
- tracerazor/schemas/tracerazor_event.schema.json +175 -0
- tracerazor/trice/__init__.py +188 -0
- tracerazor/trice/__main__.py +5 -0
- tracerazor/trice/cli.py +392 -0
- tracerazor-1.1.0.dist-info/METADATA +1756 -0
- tracerazor-1.1.0.dist-info/RECORD +128 -0
- tracerazor-1.1.0.dist-info/WHEEL +5 -0
- tracerazor-1.1.0.dist-info/entry_points.txt +4 -0
- 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
|
+
]
|
tracerazor/_adapters.py
ADDED
|
@@ -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}]"
|