Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
d26674c
feat: add executable replay runtime core
kmccleary3301 Jul 21, 2026
f4ed99f
fix: preserve live replay tool results
kmccleary3301 Jul 21, 2026
624b2f9
fix(ns09a): alias forced replay tool choices
kmccleary3301 Jul 22, 2026
4037922
fix(ns09a): fail closed on host containment
kmccleary3301 Jul 22, 2026
e32d9c9
fix(ns09a): exclude transient replay specs
kmccleary3301 Jul 22, 2026
66eeec7
fix(ns09a): filter replay provider metadata
kmccleary3301 Jul 22, 2026
9e42531
fix(ns09a): preserve replay baseline contracts
kmccleary3301 Jul 22, 2026
f56c0f2
fix(ns09a): confine host subprocess reads
kmccleary3301 Jul 22, 2026
48221cf
fix(ns09a): isolate worker module reads
kmccleary3301 Jul 22, 2026
3f75652
fix(ns09a): isolate provider workspace reads
kmccleary3301 Jul 22, 2026
70eaf86
fix(ns09a): import worker loaders explicitly
kmccleary3301 Jul 22, 2026
2a13c3f
fix(ns09a): commit provider metadata transactionally
kmccleary3301 Jul 22, 2026
55260bb
fix(ns09a): preserve failed provider status
kmccleary3301 Jul 22, 2026
04b0c60
fix(ns09a): bind runtime counter state
kmccleary3301 Jul 22, 2026
3f23c21
fix(ns09a): preserve scalar capability state
kmccleary3301 Jul 22, 2026
723eb12
fix(ns09a): round-trip composite flags
kmccleary3301 Jul 22, 2026
9ea2315
fix(ns09a): enforce idle deadline for workers
kmccleary3301 Jul 22, 2026
a929842
fix(ns09a): allow lazy package imports
kmccleary3301 Jul 22, 2026
d038457
fix(ns09a): bind replay invocation inputs
kmccleary3301 Jul 22, 2026
4713da4
fix(ns09a): close exec and entrypoint gaps
kmccleary3301 Jul 23, 2026
db9fedf
fix(ns09a): gate host network access
kmccleary3301 Jul 23, 2026
88a4e45
fix(ns09a): bind nested identity and enforce deadlines
kmccleary3301 Jul 23, 2026
0a42507
chore(ns09a): refresh pull request head
kmccleary3301 Jul 23, 2026
10a38ab
fix(ns09a): bind exchanges to raw payloads
kmccleary3301 Jul 23, 2026
aa1b127
fix(ns09a): defer provider state commit
kmccleary3301 Jul 23, 2026
7458b27
fix(ns09a): preserve reasoning and lazy imports
kmccleary3301 Jul 24, 2026
1af0b1c
fix(ns09a): gate capability startup
kmccleary3301 Jul 24, 2026
3f86cfa
fix(ns09a): bind lazy module identities
kmccleary3301 Jul 24, 2026
790ae08
fix(ns09a): bind resources and flat tool choices
kmccleary3301 Jul 24, 2026
5f40104
fix(ns09a): alias mixed provider tool choices
kmccleary3301 Jul 24, 2026
79fcfa4
fix(ns09a): recheck policy completion state
kmccleary3301 Jul 24, 2026
32c5f59
fix(ns09a): bound worker startup and module reads
kmccleary3301 Jul 24, 2026
9589335
fix(ns09a): bind provider state dependencies
kmccleary3301 Jul 24, 2026
fd62822
fix(ns09a): link provider request evidence
kmccleary3301 Jul 24, 2026
b4e9907
fix(ns09a): close replay evidence boundaries
kmccleary3301 Jul 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 169 additions & 19 deletions agentic_coder_prototype/provider/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@
from __future__ import annotations

import base64
import hashlib
import datetime
import json
import math
import os
import random
import re
from dataclasses import dataclass, field
import time
from types import SimpleNamespace
from typing import Any, Dict, List, Optional, Tuple, Type
from typing import Any, Dict, List, Mapping, Optional, Tuple, Type
import textwrap

try: # pragma: no cover - import guard exercised in runtime
Expand Down Expand Up @@ -79,6 +81,115 @@ class ProviderResult:
metadata: Dict[str, Any] = field(default_factory=dict)


def _provider_evidence_value(value: Any, *, seen: frozenset[int] = frozenset()) -> Any:
if value is None or type(value) in (bool, str, int, float):
json.dumps(value, allow_nan=False)
return value
if isinstance(value, dict):
if id(value) in seen or any(type(key) is not str for key in value):
raise ProviderRuntimeError("provider evidence contains a cyclic object or non-string key")
return {key: _provider_evidence_value(item, seen=seen | {id(value)}) for key, item in sorted(value.items())}
if isinstance(value, (list, tuple)):
if id(value) in seen:
raise ProviderRuntimeError("provider evidence contains a cyclic array")
return [_provider_evidence_value(item, seen=seen | {id(value)}) for item in value]
raise ProviderRuntimeError(f"provider evidence contains unsupported value type: {type(value).__name__}")


def provider_result_evidence(result: ProviderResult) -> Dict[str, Any]:
"""Project a provider result into stable evidence without raw SDK objects."""
if not isinstance(result, ProviderResult):
raise ProviderRuntimeError("provider returned an invalid ProviderResult")
messages: List[Dict[str, Any]] = []
for message in result.messages:
if not isinstance(message, ProviderMessage):
raise ProviderRuntimeError("provider result contains an invalid message")
tool_calls: List[Dict[str, Any]] = []
for call in message.tool_calls:
if not isinstance(call, ProviderToolCall):
raise ProviderRuntimeError("provider result contains an invalid tool call")
tool_calls.append({"id": call.id, "name": call.name, "arguments": call.arguments, "type": call.type})
messages.append({
"role": message.role,
"content": message.content,
"tool_calls": tool_calls,
"finish_reason": message.finish_reason,
"index": message.index,
"annotations": _provider_evidence_value(message.annotations),
})
return {
"messages": messages,
"usage": _provider_evidence_value(result.usage),
"encrypted_reasoning": _provider_evidence_value(result.encrypted_reasoning),
"reasoning_summaries": _provider_evidence_value(result.reasoning_summaries),
"model": result.model,
"metadata": _provider_evidence_value(result.metadata),
Comment on lines +120 to +126

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include reasoning traces in provider evidence

When Responses or Anthropic returns encrypted_reasoning or reasoning_summaries, this projection drops those normalized ProviderResult fields before run_replay hashes and stores the provider_response artifact. The normal execution path records those fields into reasoning traces, so two provider responses that differ only in reasoning evidence publish the same replay response hash and the replay loses auditable reasoning output; include replay-safe reasoning fields here or fail closed when they are present.

Useful? React with 👍 / 👎.

}


def normalized_provider_usage(result: ProviderResult) -> Dict[str, Any]:
if not isinstance(result, ProviderResult):
raise ProviderRuntimeError("provider returned an invalid ProviderResult")
if result.usage is not None and not isinstance(result.usage, dict):
raise ProviderRuntimeError("provider usage must be an object when populated")
usage = result.usage or {}

def count(*names: str) -> int:
values: List[int] = []
for name in names:
if name not in usage:
continue
value = usage[name]
if type(value) is not int or value < 0:
raise ProviderRuntimeError(f"provider usage {name} must be a nonnegative integer")
values.append(value)
if len(set(values)) > 1:
raise ProviderRuntimeError(f"provider usage aliases disagree: {', '.join(names)}")
return values[0] if values else 0

input_tokens = count("input_tokens", "prompt_tokens")
output_tokens = count("output_tokens", "completion_tokens")
component_total = input_tokens + output_tokens
reported_total = count("total_tokens")
total_tokens = max(component_total, reported_total)
if not isinstance(result.metadata, dict):
raise ProviderRuntimeError("provider metadata must be an object")
metadata = result.metadata
costs: List[Union[int, float]] = []
currencies: List[str] = []
for source in (usage, metadata):
for name in ("cost_amount", "cost", "cost_usd"):
if name not in source:
continue
value = source[name]
if isinstance(value, bool) or not isinstance(value, (int, float)) or value < 0:
raise ProviderRuntimeError(f"provider {name} must be a finite nonnegative number")
try:
finite_cost = math.isfinite(float(value))
except OverflowError as exc:
raise ProviderRuntimeError(f"provider {name} must be representable as a finite number") from exc
if not finite_cost:
raise ProviderRuntimeError(f"provider {name} must be a finite nonnegative number")
costs.append(value)
if name == "cost_usd":
currencies.append("USD")
if "cost_currency" in source:
currency_value = source["cost_currency"]
if not isinstance(currency_value, str) or len(currency_value) != 3 or any(character < "A" or character > "Z" for character in currency_value):
raise ProviderRuntimeError("provider cost_currency must be a three-letter uppercase code")
currencies.append(currency_value)
if len(set(currencies)) > 1:
raise ProviderRuntimeError("provider cost currencies disagree")
cost = max(costs, default=0.0)
currency = currencies[0] if currencies else "USD"
return {
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"total_tokens": total_tokens,
"cost_amount": cost,
"cost_currency": currency,
}

@dataclass
class ProviderRuntimeContext:
"""Context object passed to provider runtimes."""
Expand Down Expand Up @@ -847,7 +958,11 @@ def _convert_messages_to_chat(self, messages: List[Dict[str, Any]]) -> List[Dict
# Some OpenAI-compatible routes reject null `content` values.
# Normalize to empty string to preserve turn shape while staying valid.
content = ""
converted.append({"role": role, "content": content})
converted_message = {"role": role, "content": content}
for field in ("name", "tool_call_id", "tool_calls", "function_call"):
if field in message:
converted_message[field] = message[field]
converted.append(converted_message)
return converted

def _convert_tools_to_openai(self, tools: Optional[List[Dict[str, Any]]]) -> Optional[List[Dict[str, Any]]]:
Expand Down Expand Up @@ -1700,7 +1815,7 @@ def _message_content_to_text(self, content: Any) -> Optional[str]:
return None
return "".join(parts) if parts else None

def _convert_messages(self, messages: List[Dict[str, Any]]) -> Tuple[Optional[str], List[Dict[str, Any]]]:
def _convert_messages(self, messages: List[Dict[str, Any]], tool_name_aliases: Optional[Dict[str, str]] = None) -> Tuple[Optional[str], List[Dict[str, Any]]]:
system_prompt: Optional[str] = None
converted: List[Dict[str, Any]] = []

Expand Down Expand Up @@ -1748,7 +1863,7 @@ def _convert_messages(self, messages: List[Dict[str, Any]]) -> Tuple[Optional[st
{
"type": "tool_use",
"id": str(call_id),
"name": str(name),
"name": (tool_name_aliases or {}).get(str(name), str(name)),
"input": input_payload if isinstance(input_payload, dict) else {},
}
)
Expand Down Expand Up @@ -2106,14 +2221,14 @@ def _filter_anthropic_tools(
self,
tools: Optional[List[Dict[str, Any]]],
context: ProviderRuntimeContext,
) -> Optional[List[Dict[str, Any]]]:
) -> Tuple[Optional[List[Dict[str, Any]]], Dict[str, str]]:
"""
Anthropic rejects tool names with dots or other invalid characters.
Drop dotted todo* tools when todos are disabled, and strip any tool whose
name fails the provider regex ^[a-zA-Z0-9_-]{1,128}$.
"""
if not tools:
return tools
return tools, {}

agent_cfg = context.agent_config or {}
features_cfg = agent_cfg.get("features") or {}
Expand All @@ -2129,12 +2244,26 @@ def _filter_anthropic_tools(

filtered: List[Dict[str, Any]] = []
dropped: List[str] = []
aliases: Dict[str, str] = {}
used_names: set[str] = set()
for tool in tools:
name = None
try:
name = tool.get("name")
except Exception:
name = None
if not isinstance(tool, Mapping):
continue
function = tool.get("function") if tool.get("type") == "function" else None
name = function.get("name") if isinstance(function, Mapping) else tool.get("name") if tool.get("type") != "function" else None
if isinstance(name, str):
used_names.add(name)
for tool in tools:
candidate = dict(tool) if isinstance(tool, Mapping) else tool
if isinstance(tool, Mapping) and tool.get("type") == "function" and isinstance(tool.get("function"), Mapping):
function = tool["function"]
candidate = {
"name": function.get("name"),
"input_schema": function.get("parameters", {"type": "object", "properties": {}}),
}
if isinstance(function.get("description"), str):
candidate["description"] = function["description"]
name = candidate.get("name") if isinstance(candidate, dict) else None
if not name or not isinstance(name, str):
continue

Expand All @@ -2144,10 +2273,22 @@ def _filter_anthropic_tools(
continue

if not re.match(r"^[A-Za-z0-9_-]{1,128}$", name):
dropped.append(name)
continue

filtered.append(tool)
if name != "host.execute":
dropped.append(name)
continue
alias = "host_execute"
counter = 0
while alias in used_names:
suffix = hashlib.sha256(f"{name}:{counter}".encode("utf-8")).hexdigest()[:8]
alias = f"host_execute_{suffix}"
counter += 1
candidate = dict(candidate)
candidate["name"] = alias
aliases[name] = alias
used_names.add(alias)
name = alias

filtered.append(candidate)

session_state = getattr(context, "session_state", None)
if session_state is not None and dropped:
Expand All @@ -2156,7 +2297,7 @@ def _filter_anthropic_tools(
except Exception:
pass

return filtered or None
return filtered or None, aliases

def _build_system_prompt(self, system_prompt: str, prompt_cache_cfg: Dict[str, Any]) -> Any:
apply_cache = bool(prompt_cache_cfg.get("apply_to_system", True))
Expand Down Expand Up @@ -2212,8 +2353,8 @@ def invoke(
stream: bool,
context: ProviderRuntimeContext,
) -> ProviderResult:
tools = self._filter_anthropic_tools(tools, context)
system_prompt, converted_messages = self._convert_messages(messages)
tools, tool_name_aliases = self._filter_anthropic_tools(tools, context)
system_prompt, converted_messages = self._convert_messages(messages, tool_name_aliases)

anthropic_cfg = (context.agent_config.get("provider_tools") or {}).get("anthropic", {})
max_tokens = anthropic_cfg.get("max_output_tokens", 1024)
Expand Down Expand Up @@ -2249,6 +2390,9 @@ def invoke(
request["tools"] = tools

resolved_tool_choice = self._resolve_tool_choice(anthropic_cfg.get("tool_choice"), tools)
if isinstance(resolved_tool_choice, dict) and isinstance(resolved_tool_choice.get("name"), str):
resolved_tool_choice = dict(resolved_tool_choice)
resolved_tool_choice["name"] = tool_name_aliases.get(resolved_tool_choice["name"], resolved_tool_choice["name"])
if resolved_tool_choice is not None:
request["tool_choice"] = resolved_tool_choice

Expand Down Expand Up @@ -2321,7 +2465,13 @@ def _respect_delay() -> None:
context=context,
metadata=metadata,
)
return self._normalize_response(response, usage_override=usage_override)
result = self._normalize_response(response, usage_override=usage_override)
reverse_aliases = {alias: name for name, alias in tool_name_aliases.items()}
for message in result.messages:
for call in message.tool_calls:
if call.name in reverse_aliases:
call.name = reverse_aliases[call.name]
return result
except Exception as exc:
is_rate_limit = AnthropicRateLimitError is not None and isinstance(exc, AnthropicRateLimitError)
is_overloaded = False if is_rate_limit else self._is_overloaded_error(exc)
Expand Down
13 changes: 13 additions & 0 deletions agentic_coder_prototype/state/session_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -838,6 +838,19 @@ def set_provider_metadata(self, key: str, value: Any) -> None:

def get_provider_metadata(self, key: str, default: Any = None) -> Any:
return self.provider_metadata.get(key, default)
def provider_metadata_snapshot(self) -> Dict[str, Any]:
replay_safe_keys = (
"anthropic_rate_limits",
"conversation_id",
"current_turn_index",
"previous_response_id",
)
return {
key: self.provider_metadata[key]
for key in replay_safe_keys
if key in self.provider_metadata
}


def clear_provider_metadata(self) -> None:
self.provider_metadata.clear()
Expand Down
13 changes: 13 additions & 0 deletions breadboard/product/evidence/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from .lane_lock import LaneLockError, LaneResolutionError, build_lane_lock, lock_lane, validate_before_capture
from .lanes import LaneValidationError, MutableReferenceError, author_lane, init_lane, load_lane, validate_lane
from .replay_execution import ReplayArtifactManifest, ReplayExecution, ReplayExecutionError
from .replay_plan import ReplayPlan, ReplayPlanError, build_replay_plan
from .replay_runner import ReplayRunError, ReplayRunResult, ReplayScenario, run_replay
from .stage_reports import StageReport, StageStateError
from .workspace import BreadBoardWorkspace, WorkspacePathError
__all__ = [
Expand All @@ -8,11 +11,21 @@
"LaneResolutionError",
"LaneValidationError",
"MutableReferenceError",
"ReplayArtifactManifest",
"ReplayExecution",
"ReplayExecutionError",
"ReplayPlan",
"ReplayPlanError",
"ReplayRunError",
"ReplayRunResult",
"ReplayScenario",
"WorkspacePathError",
"StageReport",
"StageStateError",
"author_lane",
"build_lane_lock",
"build_replay_plan",
"run_replay",
"load_lane",
"validate_lane",
"init_lane",
Expand Down
Loading
Loading