tamfis-code 0.6.14__tar.gz → 0.7.0__tar.gz
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.
- {tamfis_code-0.6.14/tamfis_code.egg-info → tamfis_code-0.7.0}/PKG-INFO +1 -1
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/pyproject.toml +1 -1
- tamfis_code-0.7.0/tamfis_code/__init__.py +49 -0
- tamfis_code-0.7.0/tamfis_code/indexer.py +37 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/orchestrator/engine.py +61 -22
- tamfis_code-0.7.0/tamfis_code/routing.py +55 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/runner_local.py +34 -2
- tamfis_code-0.7.0/tamfis_code/runtime/__init__.py +13 -0
- tamfis_code-0.7.0/tamfis_code/runtime/budgets.py +18 -0
- tamfis_code-0.7.0/tamfis_code/runtime/controller.py +147 -0
- tamfis_code-0.7.0/tamfis_code/runtime/evidence.py +77 -0
- tamfis_code-0.7.0/tamfis_code/runtime/state.py +61 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0/tamfis_code.egg-info}/PKG-INFO +1 -1
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code.egg-info/SOURCES.txt +6 -58
- tamfis_code-0.7.0/tests/test_runtime_controller.py +55 -0
- tamfis_code-0.6.14/MANIFEST.in +0 -3
- tamfis_code-0.6.14/USAGE_INSTALL_RELEASE.md +0 -85
- tamfis_code-0.6.14/tamfis_code/__init__.py +0 -54
- tamfis_code-0.6.14/tamfis_code/indexer.py +0 -632
- tamfis_code-0.6.14/tamfis_code/routing.py +0 -165
- tamfis_code-0.6.14/tests/test_agent_definitions.py +0 -76
- tamfis_code-0.6.14/tests/test_agents.py +0 -248
- tamfis_code-0.6.14/tests/test_agents_delegation.py +0 -83
- tamfis_code-0.6.14/tests/test_build_installation.py +0 -108
- tamfis_code-0.6.14/tests/test_cli_commands.py +0 -601
- tamfis_code-0.6.14/tests/test_clipboard.py +0 -50
- tamfis_code-0.6.14/tests/test_completion.py +0 -49
- tamfis_code-0.6.14/tests/test_context_rollover.py +0 -327
- tamfis_code-0.6.14/tests/test_custom_commands.py +0 -90
- tamfis_code-0.6.14/tests/test_cwd_validation.py +0 -179
- tamfis_code-0.6.14/tests/test_doctor_session_diagnostics.py +0 -210
- tamfis_code-0.6.14/tests/test_enforcer.py +0 -178
- tamfis_code-0.6.14/tests/test_fake_tool_detection.py +0 -171
- tamfis_code-0.6.14/tests/test_find_references.py +0 -122
- tamfis_code-0.6.14/tests/test_github_command_surface.py +0 -18
- tamfis_code-0.6.14/tests/test_hooks.py +0 -144
- tamfis_code-0.6.14/tests/test_indexer.py +0 -185
- tamfis_code-0.6.14/tests/test_instructions.py +0 -75
- tamfis_code-0.6.14/tests/test_integration_new.py +0 -88
- tamfis_code-0.6.14/tests/test_intelligent_planner.py +0 -304
- tamfis_code-0.6.14/tests/test_interactive_standalone.py +0 -538
- tamfis_code-0.6.14/tests/test_live_input.py +0 -354
- tamfis_code-0.6.14/tests/test_local_pty.py +0 -36
- tamfis_code-0.6.14/tests/test_mcp.py +0 -611
- tamfis_code-0.6.14/tests/test_mcp_search_bounds.py +0 -131
- tamfis_code-0.6.14/tests/test_mcp_standalone_degradation.py +0 -46
- tamfis_code-0.6.14/tests/test_mcp_stdio_server.py +0 -113
- tamfis_code-0.6.14/tests/test_metrics.py +0 -80
- tamfis_code-0.6.14/tests/test_openhands_runtime.py +0 -113
- tamfis_code-0.6.14/tests/test_openrouter_cost_routing.py +0 -95
- tamfis_code-0.6.14/tests/test_orchestrator.py +0 -320
- tamfis_code-0.6.14/tests/test_parity_contracts.py +0 -152
- tamfis_code-0.6.14/tests/test_plan_mode_gate.py +0 -115
- tamfis_code-0.6.14/tests/test_plan_steps_and_attempts.py +0 -110
- tamfis_code-0.6.14/tests/test_provider_protocols.py +0 -94
- tamfis_code-0.6.14/tests/test_reasoning_effort_capability.py +0 -58
- tamfis_code-0.6.14/tests/test_reasoning_plan.py +0 -310
- tamfis_code-0.6.14/tests/test_references.py +0 -202
- tamfis_code-0.6.14/tests/test_render_task_diagnostics.py +0 -53
- tamfis_code-0.6.14/tests/test_routing.py +0 -268
- tamfis_code-0.6.14/tests/test_runner_helpers.py +0 -155
- tamfis_code-0.6.14/tests/test_runner_local.py +0 -2098
- tamfis_code-0.6.14/tests/test_safety.py +0 -364
- tamfis_code-0.6.14/tests/test_safety_manifest.py +0 -110
- tamfis_code-0.6.14/tests/test_swarm.py +0 -431
- tamfis_code-0.6.14/tests/test_tamfis_code_api_client.py +0 -174
- tamfis_code-0.6.14/tests/test_tamfis_code_approval.py +0 -234
- tamfis_code-0.6.14/tests/test_tamfis_code_approval_flow.py +0 -229
- tamfis_code-0.6.14/tests/test_tamfis_code_config.py +0 -329
- tamfis_code-0.6.14/tests/test_tamfis_code_intent.py +0 -100
- tamfis_code-0.6.14/tests/test_tamfis_code_render.py +0 -550
- tamfis_code-0.6.14/tests/test_tamfis_code_repl_exit.py +0 -70
- tamfis_code-0.6.14/tests/test_tamfis_code_tasks.py +0 -77
- tamfis_code-0.6.14/tests/test_tamfis_code_workspace.py +0 -487
- tamfis_code-0.6.14/tests/test_version_consistency.py +0 -40
- tamfis_code-0.6.14/tests/test_workspace_scope.py +0 -497
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/README.md +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/setup.cfg +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/__main__.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/agent_definitions.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/agents.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/api_client.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/cli.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/clipboard.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/completion.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/config.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/custom_commands.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/doctor.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/enforcer.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/evidence.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/github_commands.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/hooks.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/instructions.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/interactive.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/live_input.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/local_chat.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/local_tools.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/mcp.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/mcp_stdio_server.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/metrics.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/model_registry.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/openhands/__init__.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/openhands/agent.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/openhands/agent_server.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/openhands/automation.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/openhands/compat.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/openhands/conversation.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/openhands/delegation.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/openhands/events.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/openhands/leases.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/openhands/security.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/openhands/skills.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/openhands/tools.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/openhands/workspace.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/orchestrator/__init__.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/orchestrator/context.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/orchestrator/planner.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/orchestrator/protocols.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/orchestrator/validator.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/project.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/provider_protocols.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/providers.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/pty.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/references.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/render.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/runner.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/safety.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/screenshot.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/state.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/swarm.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/tasks.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/tool_policy.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/workspace.py +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code.egg-info/dependency_links.txt +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code.egg-info/entry_points.txt +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code.egg-info/requires.txt +0 -0
- {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tamfis-code
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: TamfisGPT Code -- portable subscription-backed terminal coding agent with local tools, PTY, plans, queues, and approvals
|
|
5
5
|
Project-URL: Homepage, https://github.com/tamfitronics/tamfis-code
|
|
6
6
|
Project-URL: Repository, https://github.com/tamfitronics/tamfis-code
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Tamfis-Code, a standalone terminal coding agent.
|
|
2
|
+
|
|
3
|
+
The package keeps import-time side effects deliberately minimal. Public
|
|
4
|
+
components are loaded lazily so a missing optional module cannot prevent the
|
|
5
|
+
CLI or deterministic runtime from starting.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from importlib import import_module
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
__version__ = "0.7.0"
|
|
13
|
+
MIN_COMPATIBLE_API_VERSION = "remote-ai-v2"
|
|
14
|
+
|
|
15
|
+
_LAZY_EXPORTS: dict[str, tuple[str, str]] = {
|
|
16
|
+
"ShellCompleter": (".completion", "ShellCompleter"),
|
|
17
|
+
"MetricsTracker": (".metrics", "MetricsTracker"),
|
|
18
|
+
"StreamMetrics": (".metrics", "StreamMetrics"),
|
|
19
|
+
"AgentManager": (".agents", "AgentManager"),
|
|
20
|
+
"SubAgent": (".agents", "SubAgent"),
|
|
21
|
+
"CodeAnalyzer": (".agents", "CodeAnalyzer"),
|
|
22
|
+
"TestGenerator": (".agents", "TestGenerator"),
|
|
23
|
+
"DocGenerator": (".agents", "DocGenerator"),
|
|
24
|
+
"MCPServer": (".mcp", "MCPServer"),
|
|
25
|
+
"ToolDefinition": (".mcp", "ToolDefinition"),
|
|
26
|
+
"call_tool": (".mcp", "call_tool"),
|
|
27
|
+
"CodeIndexer": (".indexer", "CodeIndexer"),
|
|
28
|
+
"CodeSymbol": (".indexer", "CodeSymbol"),
|
|
29
|
+
"CodeFile": (".indexer", "CodeFile"),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
__all__ = ["__version__", "MIN_COMPATIBLE_API_VERSION", *_LAZY_EXPORTS]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def __getattr__(name: str) -> Any:
|
|
36
|
+
target = _LAZY_EXPORTS.get(name)
|
|
37
|
+
if target is None:
|
|
38
|
+
raise AttributeError(name)
|
|
39
|
+
module_name, attribute = target
|
|
40
|
+
try:
|
|
41
|
+
module = import_module(module_name, __name__)
|
|
42
|
+
except ModuleNotFoundError as exc:
|
|
43
|
+
raise AttributeError(
|
|
44
|
+
f"Optional Tamfis-Code component {name!r} is unavailable because "
|
|
45
|
+
f"module {module_name!r} is not installed."
|
|
46
|
+
) from exc
|
|
47
|
+
value = getattr(module, attribute)
|
|
48
|
+
globals()[name] = value
|
|
49
|
+
return value
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Small dependency-free code index used by optional public exports."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class CodeSymbol:
|
|
9
|
+
name: str
|
|
10
|
+
kind: str
|
|
11
|
+
line: int
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class CodeFile:
|
|
15
|
+
path: str
|
|
16
|
+
language: str
|
|
17
|
+
symbols: list[CodeSymbol] = field(default_factory=list)
|
|
18
|
+
|
|
19
|
+
class CodeIndexer:
|
|
20
|
+
def __init__(self, root: str | Path): self.root=Path(root).resolve()
|
|
21
|
+
def index_file(self, path: str | Path) -> CodeFile:
|
|
22
|
+
p=Path(path).resolve(); text=p.read_text(encoding='utf-8',errors='replace')
|
|
23
|
+
suffix=p.suffix.lower(); language={'.py':'python','.ts':'typescript','.tsx':'typescript','.js':'javascript','.jsx':'javascript'}.get(suffix,'text')
|
|
24
|
+
symbols=[]
|
|
25
|
+
patterns=[('class',re.compile(r'^\s*class\s+([A-Za-z_]\w*)')),('function',re.compile(r'^\s*(?:async\s+)?def\s+([A-Za-z_]\w*)')),('function',re.compile(r'^\s*(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)'))]
|
|
26
|
+
for n,line in enumerate(text.splitlines(),1):
|
|
27
|
+
for kind,pat in patterns:
|
|
28
|
+
m=pat.search(line)
|
|
29
|
+
if m: symbols.append(CodeSymbol(m.group(1),kind,n)); break
|
|
30
|
+
return CodeFile(str(p),language,symbols)
|
|
31
|
+
def index(self) -> list[CodeFile]:
|
|
32
|
+
result=[]
|
|
33
|
+
for p in self.root.rglob('*'):
|
|
34
|
+
if p.is_file() and p.suffix.lower() in {'.py','.ts','.tsx','.js','.jsx'} and not any(part.startswith('.') for part in p.relative_to(self.root).parts):
|
|
35
|
+
try: result.append(self.index_file(p))
|
|
36
|
+
except OSError: pass
|
|
37
|
+
return result
|
|
@@ -10,6 +10,7 @@ from .context import ContextBundle, build_context_bundle
|
|
|
10
10
|
from .planner import ExecutionPlan, create_plan
|
|
11
11
|
from .protocols import AgentPhase, ToolEnvelope
|
|
12
12
|
from .validator import ValidationReport, validate_completion
|
|
13
|
+
from ..runtime import ExecutionController, GuardDecision, ObservationDecision
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
@dataclass
|
|
@@ -26,6 +27,7 @@ class OrchestrationRun:
|
|
|
26
27
|
route: dict[str, Any] = field(default_factory=dict)
|
|
27
28
|
repair_attempts: int = 0
|
|
28
29
|
reasoning_plan: bool = False
|
|
30
|
+
runtime: ExecutionController = field(default_factory=ExecutionController)
|
|
29
31
|
|
|
30
32
|
|
|
31
33
|
class AgentOrchestrator:
|
|
@@ -60,6 +62,7 @@ class AgentOrchestrator:
|
|
|
60
62
|
if profile.requires_repository_context:
|
|
61
63
|
self.transition(AgentPhase.INSPECT, action="Load or refresh repository context")
|
|
62
64
|
self.run.plan = create_plan(objective, profile)
|
|
65
|
+
self.run.runtime.start_planning()
|
|
63
66
|
plan_dict = self.run.plan.to_dict() if self.run.plan else None
|
|
64
67
|
self.run.context = build_context_bundle(
|
|
65
68
|
session_id=self.session_id, workspace_root=self.workspace_root,
|
|
@@ -85,6 +88,9 @@ class AgentOrchestrator:
|
|
|
85
88
|
for the "here is the new plan" banner; this only handles state.
|
|
86
89
|
"""
|
|
87
90
|
assert self.run is not None
|
|
91
|
+
if not self.run.runtime.record_plan_revision():
|
|
92
|
+
self.fail(self.run.runtime.snapshot.failure_reason)
|
|
93
|
+
return
|
|
88
94
|
saved = local_state.save_plan(
|
|
89
95
|
self.session_id, objective=self.run.objective,
|
|
90
96
|
content="\n".join(f"{s.index}. {s.name}" for s in plan.steps),
|
|
@@ -120,51 +126,79 @@ class AgentOrchestrator:
|
|
|
120
126
|
local_state.save_session_state(self.session_id, selected_provider=provider, selected_model=model)
|
|
121
127
|
|
|
122
128
|
def start_execution(self) -> None:
|
|
129
|
+
assert self.run is not None
|
|
130
|
+
self.run.runtime.start_execution()
|
|
123
131
|
self.transition(AgentPhase.EXECUTE, action="Execute the model/tool loop")
|
|
124
132
|
|
|
133
|
+
def guard_tool_call(self, tool_name: str, arguments: dict[str, Any]) -> GuardDecision:
|
|
134
|
+
assert self.run is not None
|
|
135
|
+
decision = self.run.runtime.guard_action(tool_name, arguments)
|
|
136
|
+
if not decision.allowed:
|
|
137
|
+
self.emit({"event_type": "diagnostics", "payload": {"content": decision.reason}})
|
|
138
|
+
return decision
|
|
139
|
+
|
|
125
140
|
def waiting_for_approval(self, purpose: str) -> None:
|
|
126
141
|
self.transition(AgentPhase.WAITING_FOR_APPROVAL, action=purpose)
|
|
127
142
|
|
|
128
|
-
def record_tool(self, envelope: ToolEnvelope) ->
|
|
143
|
+
def record_tool(self, envelope: ToolEnvelope) -> ObservationDecision:
|
|
129
144
|
assert self.run is not None
|
|
130
145
|
self.run.tool_records.append(envelope)
|
|
131
146
|
self.transition(AgentPhase.OBSERVE, action=f"Observe {envelope.tool_name} result")
|
|
147
|
+
result = {
|
|
148
|
+
"success": bool(envelope.success),
|
|
149
|
+
"result": {
|
|
150
|
+
"stdout": envelope.stdout,
|
|
151
|
+
"stderr": envelope.stderr,
|
|
152
|
+
"exit_code": envelope.exit_code,
|
|
153
|
+
"files_changed": list(envelope.files_changed),
|
|
154
|
+
"path": envelope.arguments.get("path") or envelope.arguments.get("destination"),
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
decision = self.run.runtime.observe(envelope.tool_name, envelope.arguments, result)
|
|
132
158
|
state = local_state.get_session_state(self.session_id)
|
|
133
159
|
records = state.completed_actions + [{"type": "tool", **envelope.to_dict()}]
|
|
134
|
-
local_state.save_session_state(
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
160
|
+
local_state.save_session_state(
|
|
161
|
+
self.session_id, completed_actions=records[-250:],
|
|
162
|
+
running_action={
|
|
163
|
+
"purpose": decision.reason or f"Observed {envelope.tool_name}",
|
|
164
|
+
"phase": self.run.runtime.snapshot.phase.value,
|
|
165
|
+
"runtime": self.run.runtime.snapshot.to_dict(),
|
|
166
|
+
},
|
|
167
|
+
)
|
|
168
|
+
self._advance_plan_step(decision)
|
|
169
|
+
if decision.terminal:
|
|
170
|
+
self.fail(decision.reason)
|
|
171
|
+
return decision
|
|
144
172
|
|
|
145
|
-
|
|
146
|
-
"""
|
|
173
|
+
def _advance_plan_step(self, decision: ObservationDecision) -> None:
|
|
174
|
+
"""Advance only when a successful observation gained useful evidence."""
|
|
147
175
|
assert self.run is not None
|
|
148
176
|
if self.run.plan is None or not self.run.plan.steps:
|
|
149
177
|
return
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
178
|
+
active = next((step for step in self.run.plan.steps if step.status == "in_progress"), None)
|
|
179
|
+
if active is None:
|
|
180
|
+
active = next((step for step in self.run.plan.steps if step.status == "pending"), None)
|
|
181
|
+
if active is not None:
|
|
182
|
+
active.status = "in_progress"
|
|
183
|
+
if active is not None and decision.useful:
|
|
184
|
+
active.status = "completed"
|
|
185
|
+
active.evidence.extend(item for item in decision.evidence if item not in active.evidence)
|
|
186
|
+
nxt = next((step for step in self.run.plan.steps if step.status == "pending"), None)
|
|
187
|
+
if nxt is not None:
|
|
188
|
+
nxt.status = "in_progress"
|
|
159
189
|
self._sync_plan_progress()
|
|
160
190
|
|
|
161
191
|
def mark_repair(self, reason: str) -> None:
|
|
162
192
|
assert self.run is not None
|
|
163
193
|
self.run.repair_attempts += 1
|
|
194
|
+
if not self.run.runtime.record_repair():
|
|
195
|
+
self.fail(self.run.runtime.snapshot.failure_reason)
|
|
196
|
+
return
|
|
164
197
|
self.transition(AgentPhase.REPAIR, action=reason)
|
|
165
198
|
|
|
166
199
|
def validate(self, *, final_text: str, any_mutation: bool) -> ValidationReport:
|
|
167
200
|
assert self.run is not None
|
|
201
|
+
self.run.runtime.begin_validation()
|
|
168
202
|
self.transition(AgentPhase.VALIDATE, action="Validate evidence and completion claims")
|
|
169
203
|
report = validate_completion(
|
|
170
204
|
profile=self.run.profile,
|
|
@@ -205,12 +239,17 @@ class AgentOrchestrator:
|
|
|
205
239
|
)
|
|
206
240
|
self._sync_plan_progress()
|
|
207
241
|
self.transition(AgentPhase.REPORT, action="Report only evidence-supported outcomes")
|
|
242
|
+
if report.severity == "error":
|
|
243
|
+
self.run.runtime.fail("Completion validation failed.")
|
|
244
|
+
else:
|
|
245
|
+
self.run.runtime.complete()
|
|
208
246
|
self.transition(AgentPhase.FAILED if report.severity == "error" else AgentPhase.COMPLETED)
|
|
209
247
|
local_state.checkpoint(self.session_id, reason="orchestrator_complete", summary=final_text[-1000:])
|
|
210
248
|
return report
|
|
211
249
|
|
|
212
250
|
def fail(self, error: str) -> None:
|
|
213
251
|
if self.run is not None:
|
|
252
|
+
self.run.runtime.fail(error)
|
|
214
253
|
if self.run.plan is not None:
|
|
215
254
|
for step in self.run.plan.steps:
|
|
216
255
|
if step.status == "in_progress":
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Deterministic task classification used by the standalone runtime."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TaskType(str, Enum):
|
|
8
|
+
CONVERSATION="conversation"; QUESTION="question"; INSPECT="inspect"; AUDIT="audit"
|
|
9
|
+
PLAN="plan"; EDIT="edit"; DEBUG="debug"; TEST="test"; EXECUTE="execute"
|
|
10
|
+
GIT="git"; RESEARCH="research"; MIXED="mixed"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class TaskProfile:
|
|
15
|
+
task_type: TaskType
|
|
16
|
+
complexity: str
|
|
17
|
+
requires_tools: bool
|
|
18
|
+
requires_repository_context: bool
|
|
19
|
+
requires_validation: bool
|
|
20
|
+
is_plain_conversation: bool = False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def classify_task(text: str, *, read_only: bool = False) -> TaskProfile:
|
|
24
|
+
value=(text or "").casefold()
|
|
25
|
+
def has(*words: str) -> bool: return any(word in value for word in words)
|
|
26
|
+
if has("audit", "full stack", "entire stack", "review repository", "inspect repository"):
|
|
27
|
+
kind=TaskType.AUDIT
|
|
28
|
+
elif has("debug", "fix bug", "fix error", "traceback", "failing", "broken"):
|
|
29
|
+
kind=TaskType.DEBUG
|
|
30
|
+
elif has("edit", "change", "implement", "add ", "remove ", "rewrite", "refactor", "rebuild", "remodel"):
|
|
31
|
+
kind=TaskType.EDIT
|
|
32
|
+
elif has("test", "pytest", "vitest", "unit test"):
|
|
33
|
+
kind=TaskType.TEST
|
|
34
|
+
elif has("plan", "design", "architecture"):
|
|
35
|
+
kind=TaskType.PLAN
|
|
36
|
+
elif has("git ", "commit", "push", "pull request", "branch"):
|
|
37
|
+
kind=TaskType.GIT
|
|
38
|
+
elif has("research", "web search", "look up"):
|
|
39
|
+
kind=TaskType.RESEARCH
|
|
40
|
+
elif has("run ", "execute ", "restart", "build "):
|
|
41
|
+
kind=TaskType.EXECUTE
|
|
42
|
+
elif has("inspect", "read ", "show ", "find ", "locate", "search"):
|
|
43
|
+
kind=TaskType.INSPECT
|
|
44
|
+
elif value.strip() in {"hi","hello","hey","thanks","thank you"}:
|
|
45
|
+
kind=TaskType.CONVERSATION
|
|
46
|
+
else:
|
|
47
|
+
kind=TaskType.QUESTION
|
|
48
|
+
if read_only and kind in {TaskType.EDIT,TaskType.DEBUG,TaskType.EXECUTE}:
|
|
49
|
+
kind=TaskType.INSPECT
|
|
50
|
+
plain=kind is TaskType.CONVERSATION
|
|
51
|
+
tools=not plain
|
|
52
|
+
repo=kind not in {TaskType.CONVERSATION,TaskType.RESEARCH}
|
|
53
|
+
validation=kind in {TaskType.AUDIT,TaskType.EDIT,TaskType.DEBUG,TaskType.TEST,TaskType.EXECUTE,TaskType.MIXED}
|
|
54
|
+
complexity="high" if kind in {TaskType.AUDIT,TaskType.EDIT,TaskType.DEBUG,TaskType.MIXED} else "medium" if tools else "low"
|
|
55
|
+
return TaskProfile(kind, complexity, tools, repo, validation, plain)
|
|
@@ -63,7 +63,7 @@ from .workspace import classify_root
|
|
|
63
63
|
# legitimately needs many more tool calls (read several files, make several
|
|
64
64
|
# edits, run tests, iterate). This exists only to guarantee termination if
|
|
65
65
|
# something is genuinely stuck in a loop, not to cap normal work.
|
|
66
|
-
MAX_AGENT_ROUNDS =
|
|
66
|
+
MAX_AGENT_ROUNDS = 40
|
|
67
67
|
|
|
68
68
|
# High reasoning is valuable for deliberate architecture work but makes the
|
|
69
69
|
# interactive terminal feel stalled on ordinary audits/edits. Medium is the
|
|
@@ -4762,6 +4762,29 @@ async def run_local_agent_turn(
|
|
|
4762
4762
|
except json.JSONDecodeError:
|
|
4763
4763
|
arguments = {}
|
|
4764
4764
|
|
|
4765
|
+
guard = orchestrator.guard_tool_call(tc.name, arguments)
|
|
4766
|
+
if not guard.allowed:
|
|
4767
|
+
result = {
|
|
4768
|
+
"success": False,
|
|
4769
|
+
"error": guard.reason,
|
|
4770
|
+
"runtime_blocked": True,
|
|
4771
|
+
}
|
|
4772
|
+
working_messages.append({
|
|
4773
|
+
"role": "tool",
|
|
4774
|
+
"tool_call_id": tc.call_id,
|
|
4775
|
+
"content": json.dumps(result),
|
|
4776
|
+
})
|
|
4777
|
+
renderer.handle_event({
|
|
4778
|
+
"event_type": "tool_output",
|
|
4779
|
+
"payload": {"tool": tc.name, "result": result},
|
|
4780
|
+
})
|
|
4781
|
+
if guard.terminal:
|
|
4782
|
+
_persist_turn_checkpoint(status="failed", last_error=guard.reason)
|
|
4783
|
+
orchestrator.fail(guard.reason)
|
|
4784
|
+
renderer.handle_event({"event_type": "ai_task_failed", "payload": {"error": guard.reason}})
|
|
4785
|
+
return TaskOutcome(status="failed", error=guard.reason)
|
|
4786
|
+
continue
|
|
4787
|
+
|
|
4765
4788
|
if tc.name == "retrieve_evidence":
|
|
4766
4789
|
# A pure local lookup by id -- read-only, no workspace scope
|
|
4767
4790
|
# or approval gate applies (it isn't a filesystem/shell tool
|
|
@@ -4949,7 +4972,16 @@ async def run_local_agent_turn(
|
|
|
4949
4972
|
result = await mcp_server.call_tool(tc.name, arguments)
|
|
4950
4973
|
result = _normalise_tool_result(tc.name, arguments, result, workspace_root)
|
|
4951
4974
|
envelope.finish(result=result, success=bool(result.get("success")))
|
|
4952
|
-
orchestrator.record_tool(envelope)
|
|
4975
|
+
observation = orchestrator.record_tool(envelope)
|
|
4976
|
+
if observation.terminal:
|
|
4977
|
+
working_messages.append({
|
|
4978
|
+
"role": "tool",
|
|
4979
|
+
"tool_call_id": tc.call_id,
|
|
4980
|
+
"content": json.dumps(result, default=str),
|
|
4981
|
+
})
|
|
4982
|
+
_persist_turn_checkpoint(status="failed", last_error=observation.reason)
|
|
4983
|
+
renderer.handle_event({"event_type": "ai_task_failed", "payload": {"error": observation.reason}})
|
|
4984
|
+
return TaskOutcome(status="failed", error=observation.reason)
|
|
4953
4985
|
renderer.handle_event({
|
|
4954
4986
|
"event_type": "tool_output",
|
|
4955
4987
|
"payload": {"tool": tc.name, "result": _tool_output_for_render(result)},
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Deterministic execution runtime for Tamfis-Code."""
|
|
2
|
+
from .budgets import RuntimeBudgets
|
|
3
|
+
from .controller import ExecutionController, GuardDecision, ObservationDecision
|
|
4
|
+
from .state import RuntimePhase, RuntimeSnapshot
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"ExecutionController",
|
|
8
|
+
"GuardDecision",
|
|
9
|
+
"ObservationDecision",
|
|
10
|
+
"RuntimeBudgets",
|
|
11
|
+
"RuntimePhase",
|
|
12
|
+
"RuntimeSnapshot",
|
|
13
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Hard execution budgets that prevent unbounded agent loops."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class RuntimeBudgets:
|
|
8
|
+
max_tool_calls: int = 40
|
|
9
|
+
max_identical_actions: int = 2
|
|
10
|
+
max_consecutive_empty_observations: int = 3
|
|
11
|
+
max_plan_revisions: int = 4
|
|
12
|
+
max_repair_rounds: int = 3
|
|
13
|
+
max_runtime_seconds: int = 900
|
|
14
|
+
|
|
15
|
+
def __post_init__(self) -> None:
|
|
16
|
+
for name, value in self.__dict__.items():
|
|
17
|
+
if value < 1:
|
|
18
|
+
raise ValueError(f"{name} must be at least 1")
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Deterministic controller that owns progress, budgets and stall detection."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .budgets import RuntimeBudgets
|
|
9
|
+
from .evidence import action_fingerprint, evidence_labels, is_empty_result, observation_fingerprint
|
|
10
|
+
from .state import RuntimePhase, RuntimeSnapshot
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class GuardDecision:
|
|
15
|
+
allowed: bool
|
|
16
|
+
terminal: bool = False
|
|
17
|
+
reason: str = ""
|
|
18
|
+
fingerprint: str = ""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class ObservationDecision:
|
|
23
|
+
useful: bool
|
|
24
|
+
terminal: bool = False
|
|
25
|
+
reason: str = ""
|
|
26
|
+
evidence: tuple[str, ...] = field(default_factory=tuple)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ExecutionController:
|
|
30
|
+
def __init__(self, budgets: RuntimeBudgets | None = None) -> None:
|
|
31
|
+
self.budgets = budgets or RuntimeBudgets()
|
|
32
|
+
self.snapshot = RuntimeSnapshot()
|
|
33
|
+
self.started_at = time.monotonic()
|
|
34
|
+
self._last_action = ""
|
|
35
|
+
self._last_observation = ""
|
|
36
|
+
|
|
37
|
+
def _fail(self, reason: str) -> None:
|
|
38
|
+
if not self.snapshot.terminal:
|
|
39
|
+
self.snapshot.failure_reason = reason
|
|
40
|
+
self.snapshot.transition(RuntimePhase.FAILED)
|
|
41
|
+
|
|
42
|
+
def _check_time(self) -> str:
|
|
43
|
+
elapsed = time.monotonic() - self.started_at
|
|
44
|
+
if elapsed >= self.budgets.max_runtime_seconds:
|
|
45
|
+
return f"Runtime budget exhausted after {int(elapsed)} seconds."
|
|
46
|
+
return ""
|
|
47
|
+
|
|
48
|
+
def start_planning(self) -> None:
|
|
49
|
+
if self.snapshot.phase == RuntimePhase.DISCOVER:
|
|
50
|
+
self.snapshot.transition(RuntimePhase.PLAN)
|
|
51
|
+
|
|
52
|
+
def start_execution(self) -> None:
|
|
53
|
+
if self.snapshot.phase in {RuntimePhase.DISCOVER, RuntimePhase.PLAN, RuntimePhase.OBSERVE, RuntimePhase.REPAIR}:
|
|
54
|
+
self.snapshot.transition(RuntimePhase.EXECUTE)
|
|
55
|
+
|
|
56
|
+
def guard_action(self, tool_name: str, arguments: dict[str, Any]) -> GuardDecision:
|
|
57
|
+
timeout = self._check_time()
|
|
58
|
+
if timeout:
|
|
59
|
+
self._fail(timeout)
|
|
60
|
+
return GuardDecision(False, True, timeout)
|
|
61
|
+
if self.snapshot.terminal:
|
|
62
|
+
return GuardDecision(False, True, self.snapshot.failure_reason or "Runtime is terminal.")
|
|
63
|
+
if self.snapshot.tool_calls >= self.budgets.max_tool_calls:
|
|
64
|
+
reason = f"Tool-call budget exhausted ({self.budgets.max_tool_calls})."
|
|
65
|
+
self._fail(reason)
|
|
66
|
+
return GuardDecision(False, True, reason)
|
|
67
|
+
|
|
68
|
+
fingerprint = action_fingerprint(tool_name, arguments)
|
|
69
|
+
count = self.snapshot.action_counts.get(fingerprint, 0) + 1
|
|
70
|
+
self.snapshot.action_counts[fingerprint] = count
|
|
71
|
+
self.snapshot.repeated_actions = count
|
|
72
|
+
if count > self.budgets.max_identical_actions:
|
|
73
|
+
reason = (
|
|
74
|
+
f"Blocked repeated action: {tool_name} with identical arguments was already attempted "
|
|
75
|
+
f"{count - 1} times without sufficient progress."
|
|
76
|
+
)
|
|
77
|
+
# Repeated action is refused first; terminal failure is reserved for
|
|
78
|
+
# continued no-progress pressure, so the model can choose a genuinely
|
|
79
|
+
# different strategy once.
|
|
80
|
+
terminal = self.snapshot.consecutive_empty_observations >= self.budgets.max_consecutive_empty_observations
|
|
81
|
+
if terminal:
|
|
82
|
+
self._fail(reason)
|
|
83
|
+
return GuardDecision(False, terminal, reason, fingerprint)
|
|
84
|
+
|
|
85
|
+
self.snapshot.tool_calls += 1
|
|
86
|
+
self._last_action = fingerprint
|
|
87
|
+
self.start_execution()
|
|
88
|
+
return GuardDecision(True, fingerprint=fingerprint)
|
|
89
|
+
|
|
90
|
+
def observe(self, tool_name: str, arguments: dict[str, Any], result: dict[str, Any]) -> ObservationDecision:
|
|
91
|
+
if self.snapshot.terminal:
|
|
92
|
+
return ObservationDecision(False, True, self.snapshot.failure_reason)
|
|
93
|
+
if self.snapshot.phase == RuntimePhase.EXECUTE:
|
|
94
|
+
self.snapshot.transition(RuntimePhase.OBSERVE)
|
|
95
|
+
|
|
96
|
+
empty = is_empty_result(tool_name, result)
|
|
97
|
+
fingerprint = observation_fingerprint(tool_name, result)
|
|
98
|
+
self.snapshot.observation_counts[fingerprint] = self.snapshot.observation_counts.get(fingerprint, 0) + 1
|
|
99
|
+
self._last_observation = fingerprint
|
|
100
|
+
|
|
101
|
+
if empty:
|
|
102
|
+
self.snapshot.empty_observations += 1
|
|
103
|
+
self.snapshot.consecutive_empty_observations += 1
|
|
104
|
+
count = self.snapshot.consecutive_empty_observations
|
|
105
|
+
if count >= self.budgets.max_consecutive_empty_observations:
|
|
106
|
+
reason = (
|
|
107
|
+
f"Agent stalled after {count} consecutive tool results produced no useful evidence. "
|
|
108
|
+
"The runtime stopped the loop instead of repeating reconnaissance."
|
|
109
|
+
)
|
|
110
|
+
self._fail(reason)
|
|
111
|
+
return ObservationDecision(False, True, reason)
|
|
112
|
+
self.snapshot.transition(RuntimePhase.EXECUTE)
|
|
113
|
+
return ObservationDecision(False, False, f"No useful evidence gained ({count}/{self.budgets.max_consecutive_empty_observations}).")
|
|
114
|
+
|
|
115
|
+
labels = tuple(evidence_labels(tool_name, arguments, result))
|
|
116
|
+
self.snapshot.evidence_items += len(labels) or 1
|
|
117
|
+
self.snapshot.consecutive_empty_observations = 0
|
|
118
|
+
self.snapshot.transition(RuntimePhase.EXECUTE)
|
|
119
|
+
return ObservationDecision(True, False, evidence=labels)
|
|
120
|
+
|
|
121
|
+
def begin_validation(self) -> None:
|
|
122
|
+
if self.snapshot.phase in {RuntimePhase.EXECUTE, RuntimePhase.OBSERVE, RuntimePhase.REPAIR}:
|
|
123
|
+
self.snapshot.transition(RuntimePhase.VALIDATE)
|
|
124
|
+
|
|
125
|
+
def record_repair(self) -> bool:
|
|
126
|
+
self.snapshot.repair_rounds += 1
|
|
127
|
+
if self.snapshot.repair_rounds > self.budgets.max_repair_rounds:
|
|
128
|
+
self._fail(f"Repair budget exhausted ({self.budgets.max_repair_rounds}).")
|
|
129
|
+
return False
|
|
130
|
+
if self.snapshot.phase in {RuntimePhase.EXECUTE, RuntimePhase.OBSERVE, RuntimePhase.VALIDATE}:
|
|
131
|
+
self.snapshot.transition(RuntimePhase.REPAIR)
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
def record_plan_revision(self) -> bool:
|
|
135
|
+
self.snapshot.plan_revisions += 1
|
|
136
|
+
if self.snapshot.plan_revisions > self.budgets.max_plan_revisions:
|
|
137
|
+
self._fail(f"Plan revision budget exhausted ({self.budgets.max_plan_revisions}).")
|
|
138
|
+
return False
|
|
139
|
+
return True
|
|
140
|
+
|
|
141
|
+
def complete(self) -> None:
|
|
142
|
+
self.begin_validation()
|
|
143
|
+
if self.snapshot.phase == RuntimePhase.VALIDATE:
|
|
144
|
+
self.snapshot.transition(RuntimePhase.COMPLETE)
|
|
145
|
+
|
|
146
|
+
def fail(self, reason: str) -> None:
|
|
147
|
+
self._fail(reason)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Stable action/observation fingerprints and evidence classification."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
_EMPTY_MARKERS = {"", "(empty)", "[]", "{}", "null", "none", "no matches", "no results"}
|
|
10
|
+
_SPACE_RE = re.compile(r"\s+")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _canonical(value: Any) -> str:
|
|
14
|
+
try:
|
|
15
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), default=str)
|
|
16
|
+
except Exception:
|
|
17
|
+
return repr(value)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def digest(value: Any) -> str:
|
|
21
|
+
return hashlib.sha256(_canonical(value).encode("utf-8", "replace")).hexdigest()[:24]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def action_fingerprint(tool_name: str, arguments: dict[str, Any]) -> str:
|
|
25
|
+
return f"{tool_name}:{digest(arguments)}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def normalise_text(value: Any) -> str:
|
|
29
|
+
return _SPACE_RE.sub(" ", str(value or "")).strip().casefold()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def observation_fingerprint(tool_name: str, result: dict[str, Any]) -> str:
|
|
33
|
+
payload = result.get("result") if isinstance(result.get("result"), dict) else result
|
|
34
|
+
compact = {
|
|
35
|
+
"success": bool(result.get("success")),
|
|
36
|
+
"stdout": payload.get("stdout") if isinstance(payload, dict) else "",
|
|
37
|
+
"stderr": payload.get("stderr") if isinstance(payload, dict) else "",
|
|
38
|
+
"error": result.get("error") or (payload.get("error") if isinstance(payload, dict) else ""),
|
|
39
|
+
"path": payload.get("path") if isinstance(payload, dict) else "",
|
|
40
|
+
"items": payload.get("items") if isinstance(payload, dict) else None,
|
|
41
|
+
"matches": payload.get("matches") if isinstance(payload, dict) else None,
|
|
42
|
+
"files_changed": payload.get("files_changed") if isinstance(payload, dict) else None,
|
|
43
|
+
}
|
|
44
|
+
return f"{tool_name}:{digest(compact)}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def is_empty_result(tool_name: str, result: dict[str, Any]) -> bool:
|
|
48
|
+
if not bool(result.get("success")):
|
|
49
|
+
return True
|
|
50
|
+
payload = result.get("result") if isinstance(result.get("result"), dict) else result
|
|
51
|
+
if not isinstance(payload, dict):
|
|
52
|
+
return normalise_text(payload) in _EMPTY_MARKERS
|
|
53
|
+
if any(payload.get(key) for key in ("files_changed", "path", "destination")):
|
|
54
|
+
return False
|
|
55
|
+
for key in ("items", "matches", "results", "entries"):
|
|
56
|
+
if key in payload:
|
|
57
|
+
value = payload.get(key)
|
|
58
|
+
return not bool(value)
|
|
59
|
+
text = normalise_text(payload.get("stdout") or payload.get("content") or payload.get("output") or "")
|
|
60
|
+
error = normalise_text(payload.get("stderr") or payload.get("error") or result.get("error") or "")
|
|
61
|
+
if error and not text:
|
|
62
|
+
return True
|
|
63
|
+
return text in _EMPTY_MARKERS
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def evidence_labels(tool_name: str, arguments: dict[str, Any], result: dict[str, Any]) -> list[str]:
|
|
67
|
+
if is_empty_result(tool_name, result):
|
|
68
|
+
return []
|
|
69
|
+
payload = result.get("result") if isinstance(result.get("result"), dict) else result
|
|
70
|
+
labels = [f"tool:{tool_name}"]
|
|
71
|
+
path = ""
|
|
72
|
+
if isinstance(payload, dict):
|
|
73
|
+
path = str(payload.get("path") or payload.get("destination") or "")
|
|
74
|
+
path = path or str(arguments.get("path") or arguments.get("cwd") or "")
|
|
75
|
+
if path:
|
|
76
|
+
labels.append(f"path:{path}")
|
|
77
|
+
return labels
|