pulse-coding-agent 0.1.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 (104) hide show
  1. pulse/__init__.py +5 -0
  2. pulse/__main__.py +4 -0
  3. pulse/agent.py +270 -0
  4. pulse/agent_manager.py +335 -0
  5. pulse/audit.py +70 -0
  6. pulse/auth.py +670 -0
  7. pulse/ci/github_client.py +66 -0
  8. pulse/ci/runner.py +28 -0
  9. pulse/cli.py +1075 -0
  10. pulse/cli_ui.py +977 -0
  11. pulse/config.py +167 -0
  12. pulse/context.py +960 -0
  13. pulse/conversations/__init__.py +8 -0
  14. pulse/conversations/manager.py +312 -0
  15. pulse/core/agent.py +188 -0
  16. pulse/core/planner.py +105 -0
  17. pulse/core/protocols.py +37 -0
  18. pulse/edits.py +65 -0
  19. pulse/episodic.py +93 -0
  20. pulse/eval/__init__.py +8 -0
  21. pulse/eval/trajectory_logger.py +91 -0
  22. pulse/eval/verifier.py +133 -0
  23. pulse/execution/__init__.py +5 -0
  24. pulse/execution/remote_task.py +76 -0
  25. pulse/git.py +162 -0
  26. pulse/interactive.py +234 -0
  27. pulse/mcp/__init__.py +4 -0
  28. pulse/mcp/client.py +215 -0
  29. pulse/mcp/local_tools.py +105 -0
  30. pulse/memory.py +212 -0
  31. pulse/mutations.py +283 -0
  32. pulse/orchestration/__init__.py +3 -0
  33. pulse/orchestration/orchestrator.py +162 -0
  34. pulse/patch.py +129 -0
  35. pulse/planner/__init__.py +3 -0
  36. pulse/planner/dag_planner.py +85 -0
  37. pulse/planner/execution_loop.py +159 -0
  38. pulse/production.py +235 -0
  39. pulse/provider.py +59 -0
  40. pulse/provider_keys.py +278 -0
  41. pulse/providers/__init__.py +26 -0
  42. pulse/providers/anthropic.py +65 -0
  43. pulse/providers/base.py +251 -0
  44. pulse/providers/deepseek.py +10 -0
  45. pulse/providers/failover.py +32 -0
  46. pulse/providers/gemini.py +66 -0
  47. pulse/providers/groq.py +10 -0
  48. pulse/providers/manager.py +262 -0
  49. pulse/providers/openai.py +40 -0
  50. pulse/providers/openrouter.py +20 -0
  51. pulse/py.typed +1 -0
  52. pulse/reasoning.py +570 -0
  53. pulse/refactor/__init__.py +3 -0
  54. pulse/refactor/impact_analyzer.py +44 -0
  55. pulse/repository.py +209 -0
  56. pulse/rpc.py +249 -0
  57. pulse/rule_synthesizer.py +54 -0
  58. pulse/runtime.py +217 -0
  59. pulse/safety/__init__.py +3 -0
  60. pulse/safety/safety_manager.py +97 -0
  61. pulse/sandbox/SECURITY.md +57 -0
  62. pulse/sandbox/__init__.py +57 -0
  63. pulse/sandbox/api.py +594 -0
  64. pulse/sandbox/audit.py +153 -0
  65. pulse/sandbox/backend/__init__.py +7 -0
  66. pulse/sandbox/backend/base.py +72 -0
  67. pulse/sandbox/backend/docker.py +498 -0
  68. pulse/sandbox/backend/host.py +140 -0
  69. pulse/sandbox/backend/remote.py +224 -0
  70. pulse/sandbox/errors.py +106 -0
  71. pulse/sandbox/filesystem.py +476 -0
  72. pulse/sandbox/git_safe.py +50 -0
  73. pulse/sandbox/lifecycle.py +88 -0
  74. pulse/sandbox/network.py +205 -0
  75. pulse/sandbox/path_validator.py +280 -0
  76. pulse/sandbox/policy.py +209 -0
  77. pulse/sandbox/process.py +331 -0
  78. pulse/sandbox/project.py +158 -0
  79. pulse/sandbox/python_safe.py +62 -0
  80. pulse/sandbox/remote/__init__.py +1 -0
  81. pulse/sandbox/remote/client.py +389 -0
  82. pulse/sandbox/remote/models.py +167 -0
  83. pulse/sandbox/remote/protocol.py +65 -0
  84. pulse/sandbox/remote/server.py +984 -0
  85. pulse/sandbox/remote/worker.py +175 -0
  86. pulse/sandbox/resources.py +236 -0
  87. pulse/sandbox/secrets.py +241 -0
  88. pulse/session_manager.py +365 -0
  89. pulse/software_engineer.py +189 -0
  90. pulse/storage.py +140 -0
  91. pulse/streaming.py +385 -0
  92. pulse/subprocesses.py +79 -0
  93. pulse/task_manager.py +2005 -0
  94. pulse/telemetry/__init__.py +25 -0
  95. pulse/telemetry/cost_tracker.py +95 -0
  96. pulse/telemetry/logger.py +110 -0
  97. pulse/tool_policy.py +197 -0
  98. pulse/tool_registry.py +163 -0
  99. pulse/tools.py +372 -0
  100. pulse/verification.py +118 -0
  101. pulse_coding_agent-0.1.0.dist-info/METADATA +211 -0
  102. pulse_coding_agent-0.1.0.dist-info/RECORD +104 -0
  103. pulse_coding_agent-0.1.0.dist-info/WHEEL +4 -0
  104. pulse_coding_agent-0.1.0.dist-info/entry_points.txt +4 -0
@@ -0,0 +1,25 @@
1
+ from pulse.telemetry.cost_tracker import (
2
+ BudgetExceededError,
3
+ CostTracker,
4
+ ModelPricing,
5
+ UsageRecord,
6
+ )
7
+ from pulse.telemetry.logger import (
8
+ MetricEvent,
9
+ TelemetryLogger,
10
+ correlation_scope,
11
+ get_correlation_id,
12
+ set_correlation_id,
13
+ )
14
+
15
+ __all__ = [
16
+ "BudgetExceededError",
17
+ "CostTracker",
18
+ "MetricEvent",
19
+ "ModelPricing",
20
+ "TelemetryLogger",
21
+ "UsageRecord",
22
+ "correlation_scope",
23
+ "get_correlation_id",
24
+ "set_correlation_id",
25
+ ]
@@ -0,0 +1,95 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import ClassVar
5
+
6
+
7
+ class BudgetExceededError(Exception):
8
+ """Raised when token or cost budget limit is exceeded."""
9
+
10
+
11
+ @dataclass
12
+ class ModelPricing:
13
+ prompt_price_per_1k: float
14
+ completion_price_per_1k: float
15
+
16
+
17
+ @dataclass
18
+ class UsageRecord:
19
+ prompt_tokens: int
20
+ completion_tokens: int
21
+ total_tokens: int
22
+ estimated_cost: float
23
+
24
+
25
+ class CostTracker:
26
+ """Tracks prompt/completion token usage across models (OpenAI, Gemini, OpenRouter).
27
+
28
+ Enforces maximum daily/session token and cost budgets.
29
+ """
30
+
31
+ DEFAULT_PRICING: ClassVar[dict[str, ModelPricing]] = {
32
+ "gpt-4o": ModelPricing(0.0025, 0.0100),
33
+ "gpt-4o-mini": ModelPricing(0.00015, 0.0006),
34
+ "gemini-2.0-flash": ModelPricing(0.0001, 0.0004),
35
+ "openrouter/auto": ModelPricing(0.0010, 0.0030),
36
+ "default": ModelPricing(0.0015, 0.0050),
37
+ }
38
+
39
+ def __init__(
40
+ self,
41
+ max_session_tokens: int | None = None,
42
+ max_session_cost: float | None = None,
43
+ pricing: dict[str, ModelPricing] | None = None,
44
+ ) -> None:
45
+ self.max_session_tokens = max_session_tokens
46
+ self.max_session_cost = max_session_cost
47
+ self.pricing = pricing or self.DEFAULT_PRICING
48
+
49
+ self._total_prompt_tokens: int = 0
50
+ self._total_completion_tokens: int = 0
51
+ self._total_cost: float = 0.0
52
+
53
+ @property
54
+ def total_tokens(self) -> int:
55
+ return self._total_prompt_tokens + self._total_completion_tokens
56
+
57
+ @property
58
+ def total_cost(self) -> float:
59
+ return round(self._total_cost, 6)
60
+
61
+ def record_usage(self, model: str, prompt_tokens: int, completion_tokens: int) -> UsageRecord:
62
+ added_tokens = prompt_tokens + completion_tokens
63
+
64
+ if self.max_session_tokens is not None and (self.total_tokens + added_tokens) > self.max_session_tokens:
65
+ raise BudgetExceededError(
66
+ f"Session token budget exceeded: {self.total_tokens + added_tokens} > {self.max_session_tokens}"
67
+ )
68
+
69
+ model_key = model.lower()
70
+ pricing = self.pricing.get(model_key) or self.pricing.get("default", ModelPricing(0.0015, 0.0050))
71
+
72
+ prompt_cost = (prompt_tokens / 1000.0) * pricing.prompt_price_per_1k
73
+ completion_cost = (completion_tokens / 1000.0) * pricing.completion_price_per_1k
74
+ cost = prompt_cost + completion_cost
75
+
76
+ if self.max_session_cost is not None and (self._total_cost + cost) > self.max_session_cost:
77
+ raise BudgetExceededError(
78
+ f"Session cost budget exceeded: ${self._total_cost + cost:.4f} > ${self.max_session_cost:.4f}"
79
+ )
80
+
81
+ self._total_prompt_tokens += prompt_tokens
82
+ self._total_completion_tokens += completion_tokens
83
+ self._total_cost += cost
84
+
85
+ return UsageRecord(
86
+ prompt_tokens=prompt_tokens,
87
+ completion_tokens=completion_tokens,
88
+ total_tokens=added_tokens,
89
+ estimated_cost=round(cost, 6),
90
+ )
91
+
92
+ def reset(self) -> None:
93
+ self._total_prompt_tokens = 0
94
+ self._total_completion_tokens = 0
95
+ self._total_cost = 0.0
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ import contextvars
4
+ import json
5
+ import re
6
+ import uuid
7
+ from contextlib import contextmanager
8
+ from dataclasses import asdict, dataclass, field
9
+ from datetime import UTC, datetime
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from pulse.sandbox.secrets import SecretScrubber
14
+
15
+ _CORRELATION_ID: contextvars.ContextVar[str | None] = contextvars.ContextVar(
16
+ "pulse_correlation_id", default=None
17
+ )
18
+ _SAFE_CORRELATION_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
19
+
20
+
21
+ def set_correlation_id(value: object | None = None) -> str:
22
+ candidate = str(value).strip() if value is not None else ""
23
+ correlation_id = (
24
+ candidate if _SAFE_CORRELATION_ID.fullmatch(candidate) else uuid.uuid4().hex
25
+ )
26
+ _CORRELATION_ID.set(correlation_id)
27
+ return correlation_id
28
+
29
+
30
+ def get_correlation_id() -> str:
31
+ return _CORRELATION_ID.get() or set_correlation_id()
32
+
33
+
34
+ @contextmanager
35
+ def correlation_scope(value: object | None = None):
36
+ correlation_id = str(value).strip() if value is not None else ""
37
+ if not _SAFE_CORRELATION_ID.fullmatch(correlation_id):
38
+ correlation_id = uuid.uuid4().hex
39
+ token = _CORRELATION_ID.set(correlation_id)
40
+ try:
41
+ yield correlation_id
42
+ finally:
43
+ _CORRELATION_ID.reset(token)
44
+
45
+
46
+ @dataclass
47
+ class MetricEvent:
48
+ schema_version: int = field(default=1, init=False)
49
+ timestamp: str
50
+ correlation_id: str
51
+ event_type: str
52
+ step: int | None
53
+ duration_ms: float | None
54
+ metadata: dict[str, Any]
55
+
56
+
57
+ class TelemetryLogger:
58
+ """Lightweight structured logger for agent step telemetry and execution metrics."""
59
+
60
+ def __init__(self, log_path: Path | None = None) -> None:
61
+ self.log_path = log_path
62
+ self._scrubber = SecretScrubber()
63
+
64
+ def add_secret(self, secret: str | None) -> None:
65
+ if secret:
66
+ self._scrubber.add_secret(secret)
67
+
68
+ def _sanitize(self, value: Any) -> Any:
69
+ if isinstance(value, str):
70
+ return self._scrubber.redact(value)
71
+ if isinstance(value, dict):
72
+ return {str(key): self._sanitize(item) for key, item in value.items()}
73
+ if isinstance(value, (list, tuple)):
74
+ return [self._sanitize(item) for item in value]
75
+ return value
76
+
77
+ def log_event(
78
+ self,
79
+ event_type: str,
80
+ step: int | None = None,
81
+ duration_ms: float | None = None,
82
+ **metadata: Any,
83
+ ) -> MetricEvent:
84
+ event = MetricEvent(
85
+ timestamp=datetime.now(UTC).isoformat(),
86
+ correlation_id=get_correlation_id(),
87
+ event_type=event_type,
88
+ step=step,
89
+ duration_ms=duration_ms,
90
+ metadata=self._sanitize(metadata),
91
+ )
92
+
93
+ if self.log_path:
94
+ self.log_path.parent.mkdir(parents=True, exist_ok=True)
95
+ with self.log_path.open("a", encoding="utf-8") as handle:
96
+ handle.write(json.dumps(asdict(event), separators=(",", ":")) + "\n")
97
+
98
+ return event
99
+
100
+ def log_step_execution(
101
+ self, step: int, tool_name: str | None, duration_ms: float, success: bool, **kwargs: Any
102
+ ) -> MetricEvent:
103
+ return self.log_event(
104
+ event_type="step_execution",
105
+ step=step,
106
+ duration_ms=duration_ms,
107
+ tool_name=tool_name,
108
+ success=success,
109
+ **kwargs,
110
+ )
pulse/tool_policy.py ADDED
@@ -0,0 +1,197 @@
1
+ """Typed contracts and centralized authorization for model tool calls.
2
+
3
+ Tool invocations are untrusted model output. This module keeps validation and
4
+ authorization ahead of execution, with an explicit decision that can be
5
+ audited without recording sensitive argument values.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Mapping
11
+ from dataclasses import dataclass, field
12
+ from enum import Enum
13
+ from pathlib import Path
14
+ from typing import Any, Protocol
15
+
16
+
17
+ class ToolRisk(str, Enum):
18
+ """Impact level used to decide whether human approval is required."""
19
+
20
+ LOW = "low"
21
+ MEDIUM = "medium"
22
+ HIGH = "high"
23
+
24
+
25
+ class AuthorizationDecision(str, Enum):
26
+ """The only authorization outcomes available to the registry."""
27
+
28
+ ALLOW = "allow"
29
+ ASK = "ask"
30
+ DENY = "deny"
31
+
32
+
33
+ class ArgumentKind(str, Enum):
34
+ """Supported wire-level argument types for a tool schema."""
35
+
36
+ BOOLEAN = "boolean"
37
+ INTEGER = "integer"
38
+ NUMBER = "number"
39
+ STRING = "string"
40
+ OBJECT = "object"
41
+ ARRAY = "array"
42
+ CALLABLE = "callable"
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class ToolArgument:
47
+ """A named, typed argument accepted by one tool."""
48
+
49
+ name: str
50
+ kind: ArgumentKind
51
+ required: bool = False
52
+
53
+
54
+ @dataclass(frozen=True, slots=True)
55
+ class ToolSchema:
56
+ """Strict schema for untrusted tool arguments.
57
+
58
+ Unknown arguments are rejected by default. A tool may opt out only where
59
+ it intentionally implements a command-style sub-protocol itself.
60
+ """
61
+
62
+ arguments: tuple[ToolArgument, ...] = ()
63
+ allow_extra: bool = False
64
+
65
+ def validate(self, arguments: Mapping[str, Any]) -> str | None:
66
+ declared = {argument.name: argument for argument in self.arguments}
67
+ for argument in self.arguments:
68
+ if argument.required and argument.name not in arguments:
69
+ return f"Missing required argument '{argument.name}'."
70
+
71
+ if not self.allow_extra:
72
+ extras = sorted(set(arguments) - set(declared))
73
+ if extras:
74
+ return f"Unexpected argument(s): {', '.join(extras)}."
75
+
76
+ for name, value in arguments.items():
77
+ schema_argument = declared.get(name)
78
+ if schema_argument and not _matches_kind(value, schema_argument.kind):
79
+ return f"Argument '{name}' must be a {schema_argument.kind.value}."
80
+ return None
81
+
82
+
83
+ def _matches_kind(value: Any, kind: ArgumentKind) -> bool:
84
+ if kind == ArgumentKind.BOOLEAN:
85
+ return isinstance(value, bool)
86
+ if kind == ArgumentKind.INTEGER:
87
+ return isinstance(value, int) and not isinstance(value, bool)
88
+ if kind == ArgumentKind.NUMBER:
89
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
90
+ if kind == ArgumentKind.STRING:
91
+ return isinstance(value, str)
92
+ if kind == ArgumentKind.OBJECT:
93
+ return isinstance(value, Mapping)
94
+ if kind == ArgumentKind.ARRAY:
95
+ return isinstance(value, list)
96
+ return callable(value)
97
+
98
+
99
+ @dataclass(frozen=True, slots=True)
100
+ class ToolAuthorization:
101
+ """An inspectable authorization result, safe to put in an audit log."""
102
+
103
+ decision: AuthorizationDecision
104
+ reason: str
105
+ subject_id: str
106
+ capability: str
107
+ risk: ToolRisk
108
+
109
+
110
+ class AuditRecorder(Protocol):
111
+ def record(self, action: str, file: str, detail: str) -> None: ...
112
+
113
+
114
+ @dataclass(slots=True)
115
+ class ToolPolicyEngine:
116
+ """Evaluate a capability allowlist, workspace scope, and approval policy."""
117
+
118
+ workspace: Path
119
+ subject_id: str = "local-user"
120
+ allowed_capabilities: frozenset[str] = field(default_factory=frozenset)
121
+ approval_risks: frozenset[ToolRisk] = field(
122
+ default_factory=lambda: frozenset({ToolRisk.MEDIUM, ToolRisk.HIGH})
123
+ )
124
+ audit_log: AuditRecorder | None = None
125
+
126
+ def authorize(
127
+ self,
128
+ *,
129
+ tool_name: str,
130
+ capability: str,
131
+ risk: ToolRisk,
132
+ arguments: Mapping[str, Any],
133
+ ) -> ToolAuthorization:
134
+ """Return a deterministic decision before an executor sees arguments."""
135
+ if capability not in self.allowed_capabilities:
136
+ return self._decision(
137
+ AuthorizationDecision.DENY,
138
+ f"Capability '{capability}' is not allowlisted for this runtime.",
139
+ capability,
140
+ risk,
141
+ )
142
+
143
+ target_error = self._validate_workspace_scope(arguments)
144
+ if target_error:
145
+ return self._decision(AuthorizationDecision.DENY, target_error, capability, risk)
146
+
147
+ if risk in self.approval_risks:
148
+ return self._decision(
149
+ AuthorizationDecision.ASK,
150
+ f"{risk.value.capitalize()}-risk capability '{tool_name}' requires approval.",
151
+ capability,
152
+ risk,
153
+ )
154
+ return self._decision(
155
+ AuthorizationDecision.ALLOW,
156
+ f"Capability '{tool_name}' is allowlisted for {self.subject_id}.",
157
+ capability,
158
+ risk,
159
+ )
160
+
161
+ def record(self, authorization: ToolAuthorization) -> None:
162
+ """Record a decision without persisting untrusted tool argument values."""
163
+ if self.audit_log:
164
+ self.audit_log.record(
165
+ f"tool-policy-{authorization.decision.value}",
166
+ authorization.capability,
167
+ (
168
+ f"subject={authorization.subject_id} risk={authorization.risk.value}; "
169
+ f"{authorization.reason}"
170
+ ),
171
+ )
172
+
173
+ def _decision(
174
+ self,
175
+ decision: AuthorizationDecision,
176
+ reason: str,
177
+ capability: str,
178
+ risk: ToolRisk,
179
+ ) -> ToolAuthorization:
180
+ return ToolAuthorization(decision, reason, self.subject_id, capability, risk)
181
+
182
+ def _validate_workspace_scope(self, arguments: Mapping[str, Any]) -> str | None:
183
+ """Reject paths outside the configured workspace before execution."""
184
+ root = self.workspace.resolve()
185
+ for key in ("file", "path", "source", "destination", "cwd", "working_directory"):
186
+ value = arguments.get(key)
187
+ if value is None:
188
+ continue
189
+ if not isinstance(value, str):
190
+ return f"Workspace argument '{key}' must be a string path."
191
+ candidate = Path(value)
192
+ resolved = candidate.resolve() if candidate.is_absolute() else (root / candidate).resolve()
193
+ try:
194
+ resolved.relative_to(root)
195
+ except ValueError:
196
+ return f"Workspace argument '{key}' escapes the authorized workspace."
197
+ return None
pulse/tool_registry.py ADDED
@@ -0,0 +1,163 @@
1
+ """Async, UI-independent registration and execution of Pulse capabilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from collections.abc import Awaitable, Callable, Iterable, Mapping
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, Protocol
10
+
11
+ from pulse.telemetry import get_correlation_id, set_correlation_id
12
+ from pulse.tool_policy import (
13
+ AuthorizationDecision,
14
+ ToolPolicyEngine,
15
+ ToolRisk,
16
+ ToolSchema,
17
+ )
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class ToolInvocation:
22
+ name: str | None = None
23
+ arguments: Mapping[str, Any] = field(default_factory=dict)
24
+ message: str = ""
25
+ metadata: Mapping[str, Any] = field(default_factory=dict)
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class ToolResult:
30
+ content: str
31
+ terminal: bool = True
32
+ metadata: dict[str, Any] = field(default_factory=dict)
33
+
34
+
35
+ class Tool(Protocol):
36
+ """Implement this small contract, then register an instance."""
37
+
38
+ name: str
39
+ description: str
40
+ requires_permission: bool
41
+ capability: str
42
+ risk: ToolRisk
43
+ schema: ToolSchema | None
44
+
45
+ def matches(self, invocation: ToolInvocation) -> bool: ...
46
+
47
+ async def execute(self, invocation: ToolInvocation) -> ToolResult: ...
48
+
49
+
50
+ PermissionChecker = Callable[[ToolInvocation, Tool], Awaitable[bool]]
51
+
52
+
53
+ class ToolRegistry:
54
+ def __init__(
55
+ self,
56
+ tools: Iterable[Tool] = (),
57
+ *,
58
+ permission_checker: PermissionChecker | None = None,
59
+ policy_engine: ToolPolicyEngine | None = None,
60
+ telemetry: Any | None = None,
61
+ ) -> None:
62
+ self._tools: dict[str, Tool] = {}
63
+ self._permission_checker = permission_checker
64
+ self._policy_engine = policy_engine
65
+ self._telemetry = telemetry
66
+ for tool in tools:
67
+ self.register(tool)
68
+
69
+ def register(self, tool: Tool) -> None:
70
+ if tool.name in self._tools:
71
+ raise ValueError(f"Tool already registered: {tool.name}")
72
+ self._tools[tool.name] = tool
73
+
74
+ def discover(self) -> tuple[Tool, ...]:
75
+ return tuple(self._tools.values())
76
+
77
+ def get(self, name: str) -> Tool | None:
78
+ return self._tools.get(name)
79
+
80
+ def match(self, invocation: ToolInvocation) -> Tool | None:
81
+ if invocation.name:
82
+ return self.get(invocation.name)
83
+ return next((tool for tool in self._tools.values() if tool.matches(invocation)), None)
84
+
85
+ async def execute(self, invocation: ToolInvocation) -> ToolResult | None:
86
+ tool = self.match(invocation)
87
+ if tool is None:
88
+ return None
89
+ correlation_id = set_correlation_id(
90
+ invocation.metadata.get("correlation_id") or get_correlation_id()
91
+ )
92
+ started = time.perf_counter()
93
+ if self._telemetry:
94
+ self._telemetry.log_event(
95
+ "tool_started", tool_name=tool.name, correlation_id=correlation_id
96
+ )
97
+
98
+ def finish(result: ToolResult) -> ToolResult:
99
+ result.metadata.setdefault("correlation_id", correlation_id)
100
+ if self._telemetry:
101
+ self._telemetry.log_event(
102
+ "tool_completed",
103
+ duration_ms=(time.perf_counter() - started) * 1000,
104
+ tool_name=tool.name,
105
+ error_code=result.metadata.get("error_code"),
106
+ )
107
+ return result
108
+
109
+ schema = getattr(tool, "schema", None)
110
+ if schema:
111
+ validation_error = schema.validate(invocation.arguments)
112
+ if validation_error:
113
+ return finish(ToolResult(
114
+ f"Invalid arguments for {tool.name}: {validation_error}",
115
+ metadata={"error_code": "invalid_tool_arguments", "validation_error": validation_error},
116
+ ))
117
+
118
+ requires_approval = bool(getattr(tool, "requires_permission", False))
119
+ policy_requires_approval = False
120
+ if self._policy_engine:
121
+ risk = getattr(tool, "risk", ToolRisk.LOW)
122
+ capability = getattr(tool, "capability", None) or tool.name
123
+ authorization = self._policy_engine.authorize(
124
+ tool_name=tool.name,
125
+ capability=capability,
126
+ risk=risk,
127
+ arguments=invocation.arguments,
128
+ )
129
+ self._policy_engine.record(authorization)
130
+ if authorization.decision == AuthorizationDecision.DENY:
131
+ return finish(ToolResult(
132
+ f"Tool denied for {tool.name}: {authorization.reason}",
133
+ metadata={"error_code": "tool_policy_denied", "policy_reason": authorization.reason},
134
+ ))
135
+ policy_requires_approval = authorization.decision == AuthorizationDecision.ASK
136
+ requires_approval = requires_approval or policy_requires_approval
137
+
138
+ approval_denied = (
139
+ policy_requires_approval and self._permission_checker is None
140
+ ) or (
141
+ requires_approval
142
+ and self._permission_checker is not None
143
+ and not await self._permission_checker(invocation, tool)
144
+ )
145
+ if approval_denied:
146
+ return finish(ToolResult(
147
+ f"Permission denied for {tool.name}.",
148
+ metadata={"permission_denied": True, "error_code": "tool_approval_denied"},
149
+ ))
150
+ try:
151
+ return finish(await tool.execute(invocation))
152
+ except Exception:
153
+ if self._telemetry:
154
+ self._telemetry.log_event(
155
+ "tool_failed",
156
+ duration_ms=(time.perf_counter() - started) * 1000,
157
+ tool_name=tool.name,
158
+ )
159
+ raise
160
+
161
+ async def execute_many(self, invocations: Iterable[ToolInvocation]) -> list[ToolResult | None]:
162
+ """Independent tools may run concurrently; callers retain input ordering."""
163
+ return list(await asyncio.gather(*(self.execute(invocation) for invocation in invocations)))