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.
Files changed (137) hide show
  1. {tamfis_code-0.6.14/tamfis_code.egg-info → tamfis_code-0.7.0}/PKG-INFO +1 -1
  2. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/pyproject.toml +1 -1
  3. tamfis_code-0.7.0/tamfis_code/__init__.py +49 -0
  4. tamfis_code-0.7.0/tamfis_code/indexer.py +37 -0
  5. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/orchestrator/engine.py +61 -22
  6. tamfis_code-0.7.0/tamfis_code/routing.py +55 -0
  7. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/runner_local.py +34 -2
  8. tamfis_code-0.7.0/tamfis_code/runtime/__init__.py +13 -0
  9. tamfis_code-0.7.0/tamfis_code/runtime/budgets.py +18 -0
  10. tamfis_code-0.7.0/tamfis_code/runtime/controller.py +147 -0
  11. tamfis_code-0.7.0/tamfis_code/runtime/evidence.py +77 -0
  12. tamfis_code-0.7.0/tamfis_code/runtime/state.py +61 -0
  13. {tamfis_code-0.6.14 → tamfis_code-0.7.0/tamfis_code.egg-info}/PKG-INFO +1 -1
  14. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code.egg-info/SOURCES.txt +6 -58
  15. tamfis_code-0.7.0/tests/test_runtime_controller.py +55 -0
  16. tamfis_code-0.6.14/MANIFEST.in +0 -3
  17. tamfis_code-0.6.14/USAGE_INSTALL_RELEASE.md +0 -85
  18. tamfis_code-0.6.14/tamfis_code/__init__.py +0 -54
  19. tamfis_code-0.6.14/tamfis_code/indexer.py +0 -632
  20. tamfis_code-0.6.14/tamfis_code/routing.py +0 -165
  21. tamfis_code-0.6.14/tests/test_agent_definitions.py +0 -76
  22. tamfis_code-0.6.14/tests/test_agents.py +0 -248
  23. tamfis_code-0.6.14/tests/test_agents_delegation.py +0 -83
  24. tamfis_code-0.6.14/tests/test_build_installation.py +0 -108
  25. tamfis_code-0.6.14/tests/test_cli_commands.py +0 -601
  26. tamfis_code-0.6.14/tests/test_clipboard.py +0 -50
  27. tamfis_code-0.6.14/tests/test_completion.py +0 -49
  28. tamfis_code-0.6.14/tests/test_context_rollover.py +0 -327
  29. tamfis_code-0.6.14/tests/test_custom_commands.py +0 -90
  30. tamfis_code-0.6.14/tests/test_cwd_validation.py +0 -179
  31. tamfis_code-0.6.14/tests/test_doctor_session_diagnostics.py +0 -210
  32. tamfis_code-0.6.14/tests/test_enforcer.py +0 -178
  33. tamfis_code-0.6.14/tests/test_fake_tool_detection.py +0 -171
  34. tamfis_code-0.6.14/tests/test_find_references.py +0 -122
  35. tamfis_code-0.6.14/tests/test_github_command_surface.py +0 -18
  36. tamfis_code-0.6.14/tests/test_hooks.py +0 -144
  37. tamfis_code-0.6.14/tests/test_indexer.py +0 -185
  38. tamfis_code-0.6.14/tests/test_instructions.py +0 -75
  39. tamfis_code-0.6.14/tests/test_integration_new.py +0 -88
  40. tamfis_code-0.6.14/tests/test_intelligent_planner.py +0 -304
  41. tamfis_code-0.6.14/tests/test_interactive_standalone.py +0 -538
  42. tamfis_code-0.6.14/tests/test_live_input.py +0 -354
  43. tamfis_code-0.6.14/tests/test_local_pty.py +0 -36
  44. tamfis_code-0.6.14/tests/test_mcp.py +0 -611
  45. tamfis_code-0.6.14/tests/test_mcp_search_bounds.py +0 -131
  46. tamfis_code-0.6.14/tests/test_mcp_standalone_degradation.py +0 -46
  47. tamfis_code-0.6.14/tests/test_mcp_stdio_server.py +0 -113
  48. tamfis_code-0.6.14/tests/test_metrics.py +0 -80
  49. tamfis_code-0.6.14/tests/test_openhands_runtime.py +0 -113
  50. tamfis_code-0.6.14/tests/test_openrouter_cost_routing.py +0 -95
  51. tamfis_code-0.6.14/tests/test_orchestrator.py +0 -320
  52. tamfis_code-0.6.14/tests/test_parity_contracts.py +0 -152
  53. tamfis_code-0.6.14/tests/test_plan_mode_gate.py +0 -115
  54. tamfis_code-0.6.14/tests/test_plan_steps_and_attempts.py +0 -110
  55. tamfis_code-0.6.14/tests/test_provider_protocols.py +0 -94
  56. tamfis_code-0.6.14/tests/test_reasoning_effort_capability.py +0 -58
  57. tamfis_code-0.6.14/tests/test_reasoning_plan.py +0 -310
  58. tamfis_code-0.6.14/tests/test_references.py +0 -202
  59. tamfis_code-0.6.14/tests/test_render_task_diagnostics.py +0 -53
  60. tamfis_code-0.6.14/tests/test_routing.py +0 -268
  61. tamfis_code-0.6.14/tests/test_runner_helpers.py +0 -155
  62. tamfis_code-0.6.14/tests/test_runner_local.py +0 -2098
  63. tamfis_code-0.6.14/tests/test_safety.py +0 -364
  64. tamfis_code-0.6.14/tests/test_safety_manifest.py +0 -110
  65. tamfis_code-0.6.14/tests/test_swarm.py +0 -431
  66. tamfis_code-0.6.14/tests/test_tamfis_code_api_client.py +0 -174
  67. tamfis_code-0.6.14/tests/test_tamfis_code_approval.py +0 -234
  68. tamfis_code-0.6.14/tests/test_tamfis_code_approval_flow.py +0 -229
  69. tamfis_code-0.6.14/tests/test_tamfis_code_config.py +0 -329
  70. tamfis_code-0.6.14/tests/test_tamfis_code_intent.py +0 -100
  71. tamfis_code-0.6.14/tests/test_tamfis_code_render.py +0 -550
  72. tamfis_code-0.6.14/tests/test_tamfis_code_repl_exit.py +0 -70
  73. tamfis_code-0.6.14/tests/test_tamfis_code_tasks.py +0 -77
  74. tamfis_code-0.6.14/tests/test_tamfis_code_workspace.py +0 -487
  75. tamfis_code-0.6.14/tests/test_version_consistency.py +0 -40
  76. tamfis_code-0.6.14/tests/test_workspace_scope.py +0 -497
  77. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/README.md +0 -0
  78. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/setup.cfg +0 -0
  79. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/__main__.py +0 -0
  80. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/agent_definitions.py +0 -0
  81. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/agents.py +0 -0
  82. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/api_client.py +0 -0
  83. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/cli.py +0 -0
  84. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/clipboard.py +0 -0
  85. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/completion.py +0 -0
  86. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/config.py +0 -0
  87. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/custom_commands.py +0 -0
  88. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/doctor.py +0 -0
  89. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/enforcer.py +0 -0
  90. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/evidence.py +0 -0
  91. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/github_commands.py +0 -0
  92. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/hooks.py +0 -0
  93. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/instructions.py +0 -0
  94. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/interactive.py +0 -0
  95. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/live_input.py +0 -0
  96. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/local_chat.py +0 -0
  97. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/local_tools.py +0 -0
  98. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/mcp.py +0 -0
  99. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/mcp_stdio_server.py +0 -0
  100. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/metrics.py +0 -0
  101. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/model_registry.py +0 -0
  102. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/openhands/__init__.py +0 -0
  103. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/openhands/agent.py +0 -0
  104. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/openhands/agent_server.py +0 -0
  105. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/openhands/automation.py +0 -0
  106. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/openhands/compat.py +0 -0
  107. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/openhands/conversation.py +0 -0
  108. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/openhands/delegation.py +0 -0
  109. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/openhands/events.py +0 -0
  110. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/openhands/leases.py +0 -0
  111. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/openhands/security.py +0 -0
  112. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/openhands/skills.py +0 -0
  113. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/openhands/tools.py +0 -0
  114. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/openhands/workspace.py +0 -0
  115. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/orchestrator/__init__.py +0 -0
  116. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/orchestrator/context.py +0 -0
  117. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/orchestrator/planner.py +0 -0
  118. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/orchestrator/protocols.py +0 -0
  119. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/orchestrator/validator.py +0 -0
  120. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/project.py +0 -0
  121. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/provider_protocols.py +0 -0
  122. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/providers.py +0 -0
  123. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/pty.py +0 -0
  124. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/references.py +0 -0
  125. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/render.py +0 -0
  126. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/runner.py +0 -0
  127. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/safety.py +0 -0
  128. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/screenshot.py +0 -0
  129. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/state.py +0 -0
  130. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/swarm.py +0 -0
  131. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/tasks.py +0 -0
  132. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/tool_policy.py +0 -0
  133. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code/workspace.py +0 -0
  134. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code.egg-info/dependency_links.txt +0 -0
  135. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code.egg-info/entry_points.txt +0 -0
  136. {tamfis_code-0.6.14 → tamfis_code-0.7.0}/tamfis_code.egg-info/requires.txt +0 -0
  137. {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.6.14
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tamfis-code"
3
- version = "0.6.14"
3
+ version = "0.7.0"
4
4
  description = "TamfisGPT Code -- portable subscription-backed terminal coding agent with local tools, PTY, plans, queues, and approvals"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -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) -> None:
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(self.session_id, completed_actions=records[-250:])
135
- self._advance_plan_step()
136
-
137
- def _advance_plan_step(self) -> None:
138
- """Expose execution progress without fabricating completion.
139
-
140
- A tool result proves only that a tool call occurred. It does not prove
141
- that a particular plan step completed, that a file changed, or that a
142
- validation command passed. Therefore this method may mark one pending
143
- step as in progress, but it must never mark a step completed.
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
- Final completion is assigned only by complete() after validation.
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
- # Keep at most one step active. Additional tool calls are evidence
152
- # that work continues, not that later plan steps have started.
153
- if not any(step.status == "in_progress" for step in self.run.plan.steps):
154
- for step in self.run.plan.steps:
155
- if step.status == "pending":
156
- step.status = "in_progress"
157
- break
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 = 200
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