tamfis-code 0.6.13__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.13/tamfis_code.egg-info → tamfis_code-0.7.0}/PKG-INFO +1 -1
  2. {tamfis_code-0.6.13 → 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.6.13 → tamfis_code-0.7.0}/tamfis_code/agents.py +2 -0
  5. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/enforcer.py +2 -0
  6. tamfis_code-0.7.0/tamfis_code/indexer.py +37 -0
  7. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/interactive.py +75 -22
  8. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/live_input.py +55 -7
  9. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/orchestrator/engine.py +62 -18
  10. tamfis_code-0.7.0/tamfis_code/orchestrator/planner.py +871 -0
  11. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/render.py +15 -5
  12. tamfis_code-0.7.0/tamfis_code/routing.py +55 -0
  13. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/runner_local.py +461 -35
  14. tamfis_code-0.7.0/tamfis_code/runtime/__init__.py +13 -0
  15. tamfis_code-0.7.0/tamfis_code/runtime/budgets.py +18 -0
  16. tamfis_code-0.7.0/tamfis_code/runtime/controller.py +147 -0
  17. tamfis_code-0.7.0/tamfis_code/runtime/evidence.py +77 -0
  18. tamfis_code-0.7.0/tamfis_code/runtime/state.py +61 -0
  19. {tamfis_code-0.6.13 → tamfis_code-0.7.0/tamfis_code.egg-info}/PKG-INFO +1 -1
  20. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code.egg-info/SOURCES.txt +6 -57
  21. tamfis_code-0.7.0/tests/test_runtime_controller.py +55 -0
  22. tamfis_code-0.6.13/MANIFEST.in +0 -3
  23. tamfis_code-0.6.13/USAGE_INSTALL_RELEASE.md +0 -85
  24. tamfis_code-0.6.13/tamfis_code/__init__.py +0 -54
  25. tamfis_code-0.6.13/tamfis_code/indexer.py +0 -632
  26. tamfis_code-0.6.13/tamfis_code/orchestrator/planner.py +0 -195
  27. tamfis_code-0.6.13/tamfis_code/routing.py +0 -165
  28. tamfis_code-0.6.13/tests/test_agent_definitions.py +0 -76
  29. tamfis_code-0.6.13/tests/test_agents.py +0 -248
  30. tamfis_code-0.6.13/tests/test_agents_delegation.py +0 -83
  31. tamfis_code-0.6.13/tests/test_build_installation.py +0 -108
  32. tamfis_code-0.6.13/tests/test_cli_commands.py +0 -601
  33. tamfis_code-0.6.13/tests/test_clipboard.py +0 -50
  34. tamfis_code-0.6.13/tests/test_completion.py +0 -49
  35. tamfis_code-0.6.13/tests/test_context_rollover.py +0 -327
  36. tamfis_code-0.6.13/tests/test_custom_commands.py +0 -90
  37. tamfis_code-0.6.13/tests/test_cwd_validation.py +0 -179
  38. tamfis_code-0.6.13/tests/test_doctor_session_diagnostics.py +0 -210
  39. tamfis_code-0.6.13/tests/test_enforcer.py +0 -178
  40. tamfis_code-0.6.13/tests/test_fake_tool_detection.py +0 -171
  41. tamfis_code-0.6.13/tests/test_find_references.py +0 -122
  42. tamfis_code-0.6.13/tests/test_github_command_surface.py +0 -15
  43. tamfis_code-0.6.13/tests/test_hooks.py +0 -144
  44. tamfis_code-0.6.13/tests/test_indexer.py +0 -185
  45. tamfis_code-0.6.13/tests/test_instructions.py +0 -75
  46. tamfis_code-0.6.13/tests/test_integration_new.py +0 -88
  47. tamfis_code-0.6.13/tests/test_interactive_standalone.py +0 -538
  48. tamfis_code-0.6.13/tests/test_live_input.py +0 -238
  49. tamfis_code-0.6.13/tests/test_local_pty.py +0 -36
  50. tamfis_code-0.6.13/tests/test_mcp.py +0 -611
  51. tamfis_code-0.6.13/tests/test_mcp_search_bounds.py +0 -131
  52. tamfis_code-0.6.13/tests/test_mcp_standalone_degradation.py +0 -46
  53. tamfis_code-0.6.13/tests/test_mcp_stdio_server.py +0 -113
  54. tamfis_code-0.6.13/tests/test_metrics.py +0 -80
  55. tamfis_code-0.6.13/tests/test_openhands_runtime.py +0 -113
  56. tamfis_code-0.6.13/tests/test_openrouter_cost_routing.py +0 -95
  57. tamfis_code-0.6.13/tests/test_orchestrator.py +0 -315
  58. tamfis_code-0.6.13/tests/test_parity_contracts.py +0 -152
  59. tamfis_code-0.6.13/tests/test_plan_mode_gate.py +0 -115
  60. tamfis_code-0.6.13/tests/test_plan_steps_and_attempts.py +0 -110
  61. tamfis_code-0.6.13/tests/test_provider_protocols.py +0 -94
  62. tamfis_code-0.6.13/tests/test_reasoning_effort_capability.py +0 -58
  63. tamfis_code-0.6.13/tests/test_reasoning_plan.py +0 -310
  64. tamfis_code-0.6.13/tests/test_references.py +0 -202
  65. tamfis_code-0.6.13/tests/test_render_task_diagnostics.py +0 -53
  66. tamfis_code-0.6.13/tests/test_routing.py +0 -268
  67. tamfis_code-0.6.13/tests/test_runner_helpers.py +0 -155
  68. tamfis_code-0.6.13/tests/test_runner_local.py +0 -2098
  69. tamfis_code-0.6.13/tests/test_safety.py +0 -364
  70. tamfis_code-0.6.13/tests/test_safety_manifest.py +0 -110
  71. tamfis_code-0.6.13/tests/test_swarm.py +0 -431
  72. tamfis_code-0.6.13/tests/test_tamfis_code_api_client.py +0 -174
  73. tamfis_code-0.6.13/tests/test_tamfis_code_approval.py +0 -234
  74. tamfis_code-0.6.13/tests/test_tamfis_code_approval_flow.py +0 -229
  75. tamfis_code-0.6.13/tests/test_tamfis_code_config.py +0 -329
  76. tamfis_code-0.6.13/tests/test_tamfis_code_intent.py +0 -100
  77. tamfis_code-0.6.13/tests/test_tamfis_code_render.py +0 -550
  78. tamfis_code-0.6.13/tests/test_tamfis_code_repl_exit.py +0 -70
  79. tamfis_code-0.6.13/tests/test_tamfis_code_tasks.py +0 -77
  80. tamfis_code-0.6.13/tests/test_tamfis_code_workspace.py +0 -487
  81. tamfis_code-0.6.13/tests/test_version_consistency.py +0 -40
  82. tamfis_code-0.6.13/tests/test_workspace_scope.py +0 -201
  83. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/README.md +0 -0
  84. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/setup.cfg +0 -0
  85. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/__main__.py +0 -0
  86. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/agent_definitions.py +0 -0
  87. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/api_client.py +0 -0
  88. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/cli.py +0 -0
  89. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/clipboard.py +0 -0
  90. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/completion.py +0 -0
  91. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/config.py +0 -0
  92. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/custom_commands.py +0 -0
  93. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/doctor.py +0 -0
  94. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/evidence.py +0 -0
  95. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/github_commands.py +0 -0
  96. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/hooks.py +0 -0
  97. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/instructions.py +0 -0
  98. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/local_chat.py +0 -0
  99. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/local_tools.py +0 -0
  100. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/mcp.py +0 -0
  101. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/mcp_stdio_server.py +0 -0
  102. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/metrics.py +0 -0
  103. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/model_registry.py +0 -0
  104. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/openhands/__init__.py +0 -0
  105. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/openhands/agent.py +0 -0
  106. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/openhands/agent_server.py +0 -0
  107. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/openhands/automation.py +0 -0
  108. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/openhands/compat.py +0 -0
  109. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/openhands/conversation.py +0 -0
  110. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/openhands/delegation.py +0 -0
  111. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/openhands/events.py +0 -0
  112. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/openhands/leases.py +0 -0
  113. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/openhands/security.py +0 -0
  114. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/openhands/skills.py +0 -0
  115. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/openhands/tools.py +0 -0
  116. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/openhands/workspace.py +0 -0
  117. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/orchestrator/__init__.py +0 -0
  118. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/orchestrator/context.py +0 -0
  119. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/orchestrator/protocols.py +0 -0
  120. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/orchestrator/validator.py +0 -0
  121. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/project.py +0 -0
  122. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/provider_protocols.py +0 -0
  123. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/providers.py +0 -0
  124. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/pty.py +0 -0
  125. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/references.py +0 -0
  126. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/runner.py +0 -0
  127. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/safety.py +0 -0
  128. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/screenshot.py +0 -0
  129. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/state.py +0 -0
  130. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/swarm.py +0 -0
  131. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/tasks.py +0 -0
  132. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/tool_policy.py +0 -0
  133. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code/workspace.py +0 -0
  134. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code.egg-info/dependency_links.txt +0 -0
  135. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code.egg-info/entry_points.txt +0 -0
  136. {tamfis_code-0.6.13 → tamfis_code-0.7.0}/tamfis_code.egg-info/requires.txt +0 -0
  137. {tamfis_code-0.6.13 → 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.13
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.13"
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
@@ -128,6 +128,8 @@ class CodeAnalyzer(SubAgent):
128
128
  return issues
129
129
 
130
130
  class TestGenerator(SubAgent):
131
+ # Application component, not a pytest test container.
132
+ __test__ = False
131
133
  """Generates tests for code"""
132
134
 
133
135
  def __init__(self):
@@ -20,6 +20,8 @@ except ImportError:
20
20
 
21
21
 
22
22
  class TestEnforcer:
23
+ # Application component, not a pytest test container.
24
+ __test__ = False
23
25
  """Detects and runs the current workspace's own test/build commands."""
24
26
 
25
27
  def __init__(self, workspace_root: Optional[Path] = None):
@@ -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
@@ -6,6 +6,7 @@ management.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import asyncio
9
10
  import re
10
11
  from pathlib import Path
11
12
  from typing import Optional
@@ -43,7 +44,13 @@ from .custom_commands import (
43
44
  from .doctor import run_doctor
44
45
  from .live_input import LiveInputListener
45
46
  from .render import StreamRenderer, print_banner, print_error, print_recent_thread, print_resume_plan_status, print_unified_diff
46
- from .runner import resolve_approval_decision_async, retry_task_and_stream, run_ai_task_and_stream, run_shell_command
47
+ from .runner import (
48
+ TaskOutcome,
49
+ resolve_approval_decision_async,
50
+ retry_task_and_stream,
51
+ run_ai_task_and_stream,
52
+ run_shell_command,
53
+ )
47
54
  from .runner_local import run_local_agent_turn, run_local_shell_command
48
55
  from .pty import LocalPtyBroker
49
56
  from .safety import revert_mutation as local_revert_mutation
@@ -61,6 +68,48 @@ from .workspace import (
61
68
  # those tools only collapse genuinely large pastes.
62
69
  PASTE_COLLAPSE_LINE_THRESHOLD = 3
63
70
 
71
+ async def _run_cancellable_local_turn(
72
+ *,
73
+ session_id: int,
74
+ renderer: StreamRenderer,
75
+ config: Config,
76
+ turn_coro,
77
+ ) -> TaskOutcome:
78
+ """Run one standalone agent turn with immediate terminal cancellation."""
79
+
80
+ agent_task = asyncio.create_task(turn_coro)
81
+
82
+ def _interrupt(_classification: str) -> None:
83
+ if not agent_task.done():
84
+ agent_task.cancel()
85
+
86
+ live_input = LiveInputListener(
87
+ session_id=session_id,
88
+ renderer=renderer,
89
+ cli_config=config,
90
+ interrupt_callback=_interrupt,
91
+ )
92
+ live_input.start()
93
+
94
+ try:
95
+ return await agent_task
96
+ except asyncio.CancelledError:
97
+ classification = live_input.interrupt_classification or "cancel"
98
+
99
+ if classification == "exit":
100
+ return TaskOutcome(
101
+ status="exited",
102
+ error="Exit requested by user",
103
+ )
104
+
105
+ return TaskOutcome(
106
+ status="cancelled",
107
+ error="Task cancelled by user.",
108
+ )
109
+ finally:
110
+ live_input.stop()
111
+
112
+
64
113
  HELP_TEXT = """\
65
114
  Natural-language text submits a full coding-agent task (mode: coding).
66
115
  $ <command> explicit shell command
@@ -469,19 +518,19 @@ async def run_interactive(
469
518
  model_state = local_state.get_session_state(workspace.session_id)
470
519
  plan_objective = local_state.plan_execution_objective(plan)
471
520
  if standalone:
472
- live_input = LiveInputListener(session_id=workspace.session_id, renderer=renderer, cli_config=config)
473
- live_input.start()
474
- try:
475
- outcome = await run_local_agent_turn(
521
+ outcome = await _run_cancellable_local_turn(
522
+ session_id=workspace.session_id,
523
+ renderer=renderer,
524
+ config=config,
525
+ turn_coro=run_local_agent_turn(
476
526
  provider_manager, provider_type, model,
477
527
  [*conversation_history, {"role": "user", "content": plan_objective}],
478
528
  console, renderer,
479
529
  workspace_root=workspace.workspace_root, session_id=workspace.session_id,
480
530
  approval_policy=config.approval_policy, interactive=True, cli_config=config,
481
531
  allow_swarm_tool=True,
482
- )
483
- finally:
484
- live_input.stop()
532
+ ),
533
+ )
485
534
  _append_turn_to_history(
486
535
  conversation_history, objective=plan_objective,
487
536
  answer=outcome.summary if outcome.status == "completed" else None,
@@ -1294,10 +1343,11 @@ async def run_interactive(
1294
1343
  print_error(console, "Existing uncommitted changes detected; retry is blocked to preserve user edits.")
1295
1344
  continue
1296
1345
  renderer = StreamRenderer(console, mode_label=mode_label_for_policy(config.approval_policy))
1297
- live_input = LiveInputListener(session_id=workspace.session_id, renderer=renderer, cli_config=config)
1298
- live_input.start()
1299
- try:
1300
- outcome = await run_local_agent_turn(
1346
+ outcome = await _run_cancellable_local_turn(
1347
+ session_id=workspace.session_id,
1348
+ renderer=renderer,
1349
+ config=config,
1350
+ turn_coro=run_local_agent_turn(
1301
1351
  provider_manager, provider_type, model,
1302
1352
  [*conversation_history, {"role": "user", "content": objective}],
1303
1353
  console, renderer,
@@ -1305,9 +1355,8 @@ async def run_interactive(
1305
1355
  approval_policy=config.approval_policy, interactive=True,
1306
1356
  read_only=mode in {"chat", "audit", "plan"}, cli_config=config,
1307
1357
  allow_swarm_tool=True,
1308
- )
1309
- finally:
1310
- live_input.stop()
1358
+ ),
1359
+ )
1311
1360
  renderer.finish()
1312
1361
  _append_turn_to_history(
1313
1362
  conversation_history, objective=objective,
@@ -1404,10 +1453,11 @@ async def run_interactive(
1404
1453
  "payload": {"content": submitted_text},
1405
1454
  })
1406
1455
  if standalone:
1407
- live_input = LiveInputListener(session_id=workspace.session_id, renderer=renderer, cli_config=config)
1408
- live_input.start()
1409
- try:
1410
- outcome = await run_local_agent_turn(
1456
+ outcome = await _run_cancellable_local_turn(
1457
+ session_id=workspace.session_id,
1458
+ renderer=renderer,
1459
+ config=config,
1460
+ turn_coro=run_local_agent_turn(
1411
1461
  provider_manager, provider_type, model,
1412
1462
  [*conversation_history, {"role": "user", "content": intent.objective}],
1413
1463
  console, renderer,
@@ -1415,15 +1465,18 @@ async def run_interactive(
1415
1465
  approval_policy=config.approval_policy, interactive=True,
1416
1466
  read_only=intent.mode in {"chat", "audit", "plan"}, cli_config=config,
1417
1467
  allow_swarm_tool=True,
1418
- )
1419
- finally:
1420
- live_input.stop()
1468
+ ),
1469
+ )
1421
1470
  renderer.finish()
1422
1471
  _append_turn_to_history(
1423
1472
  conversation_history, objective=intent.objective,
1424
1473
  answer=outcome.summary if outcome.status == "completed" else None,
1425
1474
  )
1426
1475
  last_turn = (intent.objective, intent.mode)
1476
+ if outcome.status == "exited":
1477
+ if local_pty is not None:
1478
+ local_pty.close()
1479
+ break
1427
1480
  if intent.mode == "plan" and outcome.status == "completed" and outcome.summary:
1428
1481
  saved = local_state.save_plan(workspace.session_id, objective=intent.objective, content=outcome.summary)
1429
1482
  console.print(f"[green]Plan saved[/green] · {saved.id}")
@@ -12,7 +12,7 @@ from __future__ import annotations
12
12
  import asyncio
13
13
  import contextlib
14
14
  import sys
15
- from typing import Optional
15
+ from typing import Callable, Optional
16
16
 
17
17
  from prompt_toolkit.formatted_text import HTML
18
18
 
@@ -30,10 +30,19 @@ _CTRL_Y = b"\x19"
30
30
  class LiveInputListener:
31
31
  """Run a persistent, asynchronous follow-up editor during a task."""
32
32
 
33
- def __init__(self, *, session_id: int, renderer: StreamRenderer, cli_config: Config) -> None:
33
+ def __init__(
34
+ self,
35
+ *,
36
+ session_id: int,
37
+ renderer: StreamRenderer,
38
+ cli_config: Config,
39
+ interrupt_callback: Optional[Callable[[str], None]] = None,
40
+ ) -> None:
34
41
  self.session_id = session_id
35
42
  self.renderer = renderer
36
43
  self.cli_config = cli_config
44
+ self._interrupt_callback = interrupt_callback
45
+ self._interrupt_classification: Optional[str] = None
37
46
  self._is_tty = bool(getattr(sys.stdin, "isatty", lambda: False)())
38
47
  self._input_task: Optional[asyncio.Task] = None
39
48
  self._interject_task: Optional[asyncio.Task] = None
@@ -136,11 +145,15 @@ class LiveInputListener:
136
145
 
137
146
  @bindings.add("escape")
138
147
  def _cancel_running_turn(event) -> None:
139
- # Match the interrupt affordance offered by other terminal agents:
140
- # Escape cancels the active turn, while the prompt remains usable
141
- # for subsequent work once the runner reaches a safe boundary.
142
- self._enqueue_control("cancel")
143
- event.app.exit(result="")
148
+ # Escape cancels the active turn immediately and returns control
149
+ # to the ordinary REPL without terminating Tamfis-Code.
150
+ if self._paused or not self._active:
151
+ return
152
+
153
+ self._request_interrupt("cancel")
154
+
155
+ if not event.app.is_done:
156
+ event.app.exit(result="")
144
157
 
145
158
  session = PromptSession(key_bindings=bindings)
146
159
  self._prompt_session = session
@@ -163,9 +176,44 @@ class LiveInputListener:
163
176
  return
164
177
  except EOFError:
165
178
  return
179
+
180
+ # Escape sets _paused before exiting the prompt, so its empty
181
+ # result must not be queued or cause another editor to start.
182
+ if self._paused:
183
+ break
184
+
185
+ # A genuine line already returned by prompt_async remains valid
186
+ # even when the listener was deactivated during that prompt.
187
+ # Queue it first, then let the loop terminate cleanly.
166
188
  self._enqueue(text)
189
+
190
+ if not self._active:
191
+ break
192
+
167
193
  self._prompt_session = None
168
194
 
195
+ @property
196
+ def interrupt_classification(self) -> Optional[str]:
197
+ return self._interrupt_classification
198
+
199
+ def _request_interrupt(self, classification: str) -> None:
200
+ """Record and immediately propagate one terminal interrupt.
201
+
202
+ The durable queue item remains useful for history and recovery, but
203
+ immediate cancellation is delivered through interrupt_callback so a
204
+ blocked provider request or long-running tool does not need to reach
205
+ another orchestration boundary first.
206
+ """
207
+ if self._interrupt_classification is not None:
208
+ return
209
+
210
+ self._interrupt_classification = classification
211
+ self._paused = True
212
+ self._enqueue_control(classification)
213
+
214
+ if self._interrupt_callback is not None:
215
+ self._interrupt_callback(classification)
216
+
169
217
  def _enqueue_control(self, classification: str) -> None:
170
218
  item = local_state.enqueue_instruction(
171
219
  self.session_id, "", classification=classification,
@@ -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,46 +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
- """Best-effort plan-step progress: each observed tool result is real,
139
- concrete evidence that execution has moved forward, so use the
140
- running tool-call count to advance a cursor through the plan's
141
- steps -- reserving the final step (report/validate) so it is never
142
- marked done before completion actually happens. This does not claim
143
- precise step-to-action correspondence (see _sync_plan_progress)."""
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
172
+
173
+ def _advance_plan_step(self, decision: ObservationDecision) -> None:
174
+ """Advance only when a successful observation gained useful evidence."""
144
175
  assert self.run is not None
145
176
  if self.run.plan is None or not self.run.plan.steps:
146
177
  return
147
- steps = self.run.plan.steps
148
- cursor = min(len(self.run.tool_records) - 1, max(len(steps) - 2, 0))
149
- for step in steps[:cursor]:
150
- if step.status not in {"completed", "failed"}:
151
- step.status = "completed"
152
- if steps[cursor].status not in {"completed", "failed"}:
153
- steps[cursor].status = "in_progress"
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"
154
189
  self._sync_plan_progress()
155
190
 
156
191
  def mark_repair(self, reason: str) -> None:
157
192
  assert self.run is not None
158
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
159
197
  self.transition(AgentPhase.REPAIR, action=reason)
160
198
 
161
199
  def validate(self, *, final_text: str, any_mutation: bool) -> ValidationReport:
162
200
  assert self.run is not None
201
+ self.run.runtime.begin_validation()
163
202
  self.transition(AgentPhase.VALIDATE, action="Validate evidence and completion claims")
164
203
  report = validate_completion(
165
204
  profile=self.run.profile,
@@ -200,12 +239,17 @@ class AgentOrchestrator:
200
239
  )
201
240
  self._sync_plan_progress()
202
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()
203
246
  self.transition(AgentPhase.FAILED if report.severity == "error" else AgentPhase.COMPLETED)
204
247
  local_state.checkpoint(self.session_id, reason="orchestrator_complete", summary=final_text[-1000:])
205
248
  return report
206
249
 
207
250
  def fail(self, error: str) -> None:
208
251
  if self.run is not None:
252
+ self.run.runtime.fail(error)
209
253
  if self.run.plan is not None:
210
254
  for step in self.run.plan.steps:
211
255
  if step.status == "in_progress":