tamfis-code 0.2.3__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 (68) hide show
  1. tamfis_code-0.2.3/PKG-INFO +78 -0
  2. tamfis_code-0.2.3/README.md +59 -0
  3. tamfis_code-0.2.3/pyproject.toml +44 -0
  4. tamfis_code-0.2.3/setup.cfg +4 -0
  5. tamfis_code-0.2.3/tamfis_code/__init__.py +58 -0
  6. tamfis_code-0.2.3/tamfis_code/__main__.py +4 -0
  7. tamfis_code-0.2.3/tamfis_code/agents.py +371 -0
  8. tamfis_code-0.2.3/tamfis_code/api_client.py +461 -0
  9. tamfis_code-0.2.3/tamfis_code/cli.py +2351 -0
  10. tamfis_code-0.2.3/tamfis_code/completion.py +137 -0
  11. tamfis_code-0.2.3/tamfis_code/config.py +199 -0
  12. tamfis_code-0.2.3/tamfis_code/doctor.py +173 -0
  13. tamfis_code-0.2.3/tamfis_code/enforcer.py +318 -0
  14. tamfis_code-0.2.3/tamfis_code/indexer.py +567 -0
  15. tamfis_code-0.2.3/tamfis_code/instructions.py +121 -0
  16. tamfis_code-0.2.3/tamfis_code/interactive.py +985 -0
  17. tamfis_code-0.2.3/tamfis_code/local_chat.py +117 -0
  18. tamfis_code-0.2.3/tamfis_code/local_tools.py +93 -0
  19. tamfis_code-0.2.3/tamfis_code/mcp.py +538 -0
  20. tamfis_code-0.2.3/tamfis_code/metrics.py +105 -0
  21. tamfis_code-0.2.3/tamfis_code/providers.py +423 -0
  22. tamfis_code-0.2.3/tamfis_code/references.py +336 -0
  23. tamfis_code-0.2.3/tamfis_code/render.py +575 -0
  24. tamfis_code-0.2.3/tamfis_code/runner.py +635 -0
  25. tamfis_code-0.2.3/tamfis_code/runner_local.py +364 -0
  26. tamfis_code-0.2.3/tamfis_code/safety.py +164 -0
  27. tamfis_code-0.2.3/tamfis_code/screenshot.py +382 -0
  28. tamfis_code-0.2.3/tamfis_code/sessions.py +253 -0
  29. tamfis_code-0.2.3/tamfis_code/state.py +449 -0
  30. tamfis_code-0.2.3/tamfis_code/tasks.py +42 -0
  31. tamfis_code-0.2.3/tamfis_code/workspace.py +329 -0
  32. tamfis_code-0.2.3/tamfis_code.egg-info/PKG-INFO +78 -0
  33. tamfis_code-0.2.3/tamfis_code.egg-info/SOURCES.txt +66 -0
  34. tamfis_code-0.2.3/tamfis_code.egg-info/dependency_links.txt +1 -0
  35. tamfis_code-0.2.3/tamfis_code.egg-info/entry_points.txt +4 -0
  36. tamfis_code-0.2.3/tamfis_code.egg-info/requires.txt +11 -0
  37. tamfis_code-0.2.3/tamfis_code.egg-info/top_level.txt +1 -0
  38. tamfis_code-0.2.3/tests/test_agents.py +248 -0
  39. tamfis_code-0.2.3/tests/test_agents_delegation.py +83 -0
  40. tamfis_code-0.2.3/tests/test_cli_commands.py +381 -0
  41. tamfis_code-0.2.3/tests/test_completion.py +49 -0
  42. tamfis_code-0.2.3/tests/test_doctor_session_diagnostics.py +111 -0
  43. tamfis_code-0.2.3/tests/test_enforcer.py +149 -0
  44. tamfis_code-0.2.3/tests/test_indexer.py +109 -0
  45. tamfis_code-0.2.3/tests/test_instructions.py +75 -0
  46. tamfis_code-0.2.3/tests/test_integration_new.py +101 -0
  47. tamfis_code-0.2.3/tests/test_interactive_standalone.py +173 -0
  48. tamfis_code-0.2.3/tests/test_local_mode.py +144 -0
  49. tamfis_code-0.2.3/tests/test_mcp.py +208 -0
  50. tamfis_code-0.2.3/tests/test_mcp_standalone_degradation.py +46 -0
  51. tamfis_code-0.2.3/tests/test_metrics.py +80 -0
  52. tamfis_code-0.2.3/tests/test_plan_steps_and_attempts.py +110 -0
  53. tamfis_code-0.2.3/tests/test_references.py +202 -0
  54. tamfis_code-0.2.3/tests/test_render_task_diagnostics.py +53 -0
  55. tamfis_code-0.2.3/tests/test_runner_helpers.py +155 -0
  56. tamfis_code-0.2.3/tests/test_runner_local.py +237 -0
  57. tamfis_code-0.2.3/tests/test_safety.py +158 -0
  58. tamfis_code-0.2.3/tests/test_sessions.py +140 -0
  59. tamfis_code-0.2.3/tests/test_tamfis_code_api_client.py +174 -0
  60. tamfis_code-0.2.3/tests/test_tamfis_code_approval.py +106 -0
  61. tamfis_code-0.2.3/tests/test_tamfis_code_approval_flow.py +229 -0
  62. tamfis_code-0.2.3/tests/test_tamfis_code_config.py +215 -0
  63. tamfis_code-0.2.3/tests/test_tamfis_code_intent.py +71 -0
  64. tamfis_code-0.2.3/tests/test_tamfis_code_render.py +181 -0
  65. tamfis_code-0.2.3/tests/test_tamfis_code_repl_exit.py +70 -0
  66. tamfis_code-0.2.3/tests/test_tamfis_code_tasks.py +77 -0
  67. tamfis_code-0.2.3/tests/test_tamfis_code_workspace.py +260 -0
  68. tamfis_code-0.2.3/tests/test_workspace_discovery_gaps.py +119 -0
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: tamfis-code
3
+ Version: 0.2.3
4
+ Summary: TamfisGPT Code -- standalone terminal coding agent (calls HF/NVIDIA NIM/OpenRouter/Ollama directly; --remote for the legacy TamfisGPT Remote backend)
5
+ Project-URL: Homepage, https://github.com/tamfitronics/tamfis-code
6
+ Project-URL: Repository, https://github.com/tamfitronics/tamfis-code
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: click>=8.1
10
+ Requires-Dist: httpx>=0.27
11
+ Requires-Dist: rich>=13.0
12
+ Requires-Dist: prompt_toolkit>=3.0
13
+ Requires-Dist: openai>=1.30
14
+ Provides-Extra: dev
15
+ Requires-Dist: build>=1.0; extra == "dev"
16
+ Requires-Dist: twine>=5.0; extra == "dev"
17
+ Requires-Dist: pytest>=8.0; extra == "dev"
18
+ Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
19
+
20
+ # tamfis-code
21
+
22
+ A standalone terminal coding agent. By default it calls an LLM provider
23
+ directly — Hugging Face, NVIDIA NIM, OpenRouter, or Ollama — and runs its
24
+ own agent loop, tool execution, and local risk/approval/mutation-ledger
25
+ safety layer, with no separate backend process required.
26
+
27
+ `--remote` (or a persistent `default_backend = "remote"` config setting)
28
+ switches to the original architecture: a thin client to the TamfisGPT
29
+ Remote Workspace backend, for TamfisGPT tenants using their hosted account
30
+ the same way Codex CLI uses a ChatGPT/OpenAI account, kimi-code uses a Kimi
31
+ account, or Claude Code uses a Claude account.
32
+
33
+ ## Install
34
+
35
+ ```
36
+ pipx install tamfis-code
37
+ ```
38
+
39
+ See [USAGE_INSTALL_RELEASE.md](./USAGE_INSTALL_RELEASE.md) for installing
40
+ from source, using a TamfisGPT tenancy, and the release process.
41
+
42
+ ## Quick start
43
+
44
+ ```
45
+ export OLLAMA_BASE_URL=http://localhost:11434/v1 # or set HF_TOKEN / NVIDIA_API_KEY / OPENROUTER_API_KEY
46
+ tamfis-code doctor # check provider connectivity
47
+ tamfis-code ask "explain what this repo does"
48
+ tamfis-code agent "add a health-check endpoint" # full read/write/execute loop
49
+ tamfis-code # interactive REPL
50
+ ```
51
+
52
+ ## Providers
53
+
54
+ | Provider | Env var | Notes |
55
+ |---|---|---|
56
+ | Ollama | `OLLAMA_BASE_URL` (default `http://localhost:11434/v1`) | Runs fully on-device, no API key |
57
+ | Hugging Face | `HF_TOKEN` | |
58
+ | NVIDIA NIM | `NVIDIA_API_KEY` | |
59
+ | OpenRouter | `OPENROUTER_API_KEY` | |
60
+
61
+ Select one explicitly with `--provider hf|nvidia|openrouter|ollama`, or
62
+ leave it as `auto` (default) to pick the best configured one.
63
+
64
+ ## Safety model
65
+
66
+ Every mutating tool call (`write_file`, `edit_file`, `execute_command`) is
67
+ risk-classified locally (`tamfis_code/safety.py`), gated by
68
+ `--approval-policy` (or the `/mode` REPL command), and recorded in a local
69
+ mutation ledger — see `tamfis-code diffs` / `tamfis-code diff` /
70
+ `tamfis-code revert`. There is no sandboxing beyond risk classification;
71
+ review dangerous commands yourself before approving them.
72
+
73
+ ## License
74
+
75
+ TBD — decide before the first public PyPI release (a public package still
76
+ needs a stated license; "all rights reserved"/proprietary is a valid choice
77
+ if you don't want redistribution/modification, but it should be explicit
78
+ rather than absent).
@@ -0,0 +1,59 @@
1
+ # tamfis-code
2
+
3
+ A standalone terminal coding agent. By default it calls an LLM provider
4
+ directly — Hugging Face, NVIDIA NIM, OpenRouter, or Ollama — and runs its
5
+ own agent loop, tool execution, and local risk/approval/mutation-ledger
6
+ safety layer, with no separate backend process required.
7
+
8
+ `--remote` (or a persistent `default_backend = "remote"` config setting)
9
+ switches to the original architecture: a thin client to the TamfisGPT
10
+ Remote Workspace backend, for TamfisGPT tenants using their hosted account
11
+ the same way Codex CLI uses a ChatGPT/OpenAI account, kimi-code uses a Kimi
12
+ account, or Claude Code uses a Claude account.
13
+
14
+ ## Install
15
+
16
+ ```
17
+ pipx install tamfis-code
18
+ ```
19
+
20
+ See [USAGE_INSTALL_RELEASE.md](./USAGE_INSTALL_RELEASE.md) for installing
21
+ from source, using a TamfisGPT tenancy, and the release process.
22
+
23
+ ## Quick start
24
+
25
+ ```
26
+ export OLLAMA_BASE_URL=http://localhost:11434/v1 # or set HF_TOKEN / NVIDIA_API_KEY / OPENROUTER_API_KEY
27
+ tamfis-code doctor # check provider connectivity
28
+ tamfis-code ask "explain what this repo does"
29
+ tamfis-code agent "add a health-check endpoint" # full read/write/execute loop
30
+ tamfis-code # interactive REPL
31
+ ```
32
+
33
+ ## Providers
34
+
35
+ | Provider | Env var | Notes |
36
+ |---|---|---|
37
+ | Ollama | `OLLAMA_BASE_URL` (default `http://localhost:11434/v1`) | Runs fully on-device, no API key |
38
+ | Hugging Face | `HF_TOKEN` | |
39
+ | NVIDIA NIM | `NVIDIA_API_KEY` | |
40
+ | OpenRouter | `OPENROUTER_API_KEY` | |
41
+
42
+ Select one explicitly with `--provider hf|nvidia|openrouter|ollama`, or
43
+ leave it as `auto` (default) to pick the best configured one.
44
+
45
+ ## Safety model
46
+
47
+ Every mutating tool call (`write_file`, `edit_file`, `execute_command`) is
48
+ risk-classified locally (`tamfis_code/safety.py`), gated by
49
+ `--approval-policy` (or the `/mode` REPL command), and recorded in a local
50
+ mutation ledger — see `tamfis-code diffs` / `tamfis-code diff` /
51
+ `tamfis-code revert`. There is no sandboxing beyond risk classification;
52
+ review dangerous commands yourself before approving them.
53
+
54
+ ## License
55
+
56
+ TBD — decide before the first public PyPI release (a public package still
57
+ needs a stated license; "all rights reserved"/proprietary is a valid choice
58
+ if you don't want redistribution/modification, but it should be explicit
59
+ rather than absent).
@@ -0,0 +1,44 @@
1
+ [project]
2
+ name = "tamfis-code"
3
+ version = "0.2.3"
4
+ description = "TamfisGPT Code -- standalone terminal coding agent (calls HF/NVIDIA NIM/OpenRouter/Ollama directly; --remote for the legacy TamfisGPT Remote backend)"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ dependencies = [
8
+ "click>=8.1",
9
+ "httpx>=0.27",
10
+ "rich>=13.0",
11
+ "prompt_toolkit>=3.0",
12
+ # Provider calls (providers.py) go through the openai SDK's client against
13
+ # each vendor's OpenAI-compatible endpoint (HF, NVIDIA NIM, OpenRouter,
14
+ # Ollama) -- this was imported but never declared, only working because a
15
+ # copy happened to already be installed system-wide.
16
+ "openai>=1.30",
17
+ ]
18
+
19
+ [project.scripts]
20
+ tamfis-code = "tamfis_code.cli:main"
21
+ tamgpt-code = "tamfis_code.cli:main"
22
+ tamfis = "tamfis_code.cli:main"
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/tamfitronics/tamfis-code"
26
+ Repository = "https://github.com/tamfitronics/tamfis-code"
27
+
28
+ [project.optional-dependencies]
29
+ dev = [
30
+ "build>=1.0",
31
+ "twine>=5.0",
32
+ "pytest>=8.0",
33
+ # tests/test_agents.py and tests/test_mcp.py use @pytest.mark.asyncio --
34
+ # this was only working locally because a copy happened to already be
35
+ # installed system-wide (same class of gap as the openai dependency above).
36
+ "pytest-asyncio>=0.24",
37
+ ]
38
+
39
+ [build-system]
40
+ requires = ["setuptools>=68"]
41
+ build-backend = "setuptools.build_meta"
42
+
43
+ [tool.setuptools]
44
+ packages = ["tamfis_code"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,58 @@
1
+ """TamfisGPT Code -- a standalone terminal coding agent.
2
+
3
+ By default, this package calls an LLM provider directly (Hugging Face,
4
+ NVIDIA NIM, OpenRouter, or Ollama -- see providers.py/runner_local.py) and
5
+ runs its own agent loop, tool execution (mcp.py), and local risk
6
+ classification/approval/mutation ledger (safety.py) -- no separate backend
7
+ process required. Every `ask`/`chat`/`audit`/`plan`/`agent`/`exec` command,
8
+ and the interactive REPL, work this way unless `--remote` is passed.
9
+
10
+ `--remote` still supports the original architecture this package started
11
+ as: a thin client to the TamfisGPT Remote Workspace backend (same sessions/
12
+ tasks/tools/approvals/events -- see tier_ii_gateway/api/remote.py), which
13
+ does the equivalent work server-side. That path is kept for continuity but
14
+ is not the primary, developed-going-forward one.
15
+
16
+ See docs/REMOTE_AGENT_MASTER_SPEC.md, Phase 21, for the original --remote
17
+ architecture's spec.
18
+ """
19
+
20
+ __version__ = "0.2.3"
21
+
22
+ # Bumped whenever a CLI release requires a minimum backend Remote API
23
+ # contract version. Only meaningful for --remote; the standalone path has no
24
+ # server to negotiate a contract version with. There is no server-side
25
+ # version negotiation yet (`tamfis-code doctor --remote` just checks
26
+ # reachability) -- this constant exists so that check has something concrete
27
+ # to compare against once one is added, rather than silently assuming
28
+ # compatibility forever.
29
+ MIN_COMPATIBLE_API_VERSION = "remote-ai-v2"
30
+
31
+ # New exports
32
+ from .completion import ShellCompleter
33
+ from .metrics import MetricsTracker, StreamMetrics
34
+ from .sessions import SessionManager, Session, Message
35
+ from .agents import AgentManager, SubAgent, CodeAnalyzer, TestGenerator, DocGenerator
36
+ from .mcp import MCPServer, ToolDefinition, call_tool
37
+ from .indexer import CodeIndexer, CodeSymbol, CodeFile
38
+
39
+ __all__ = [
40
+ # Existing...
41
+ 'ShellCompleter',
42
+ 'MetricsTracker',
43
+ 'StreamMetrics',
44
+ 'SessionManager',
45
+ 'Session',
46
+ 'Message',
47
+ 'AgentManager',
48
+ 'SubAgent',
49
+ 'CodeAnalyzer',
50
+ 'TestGenerator',
51
+ 'DocGenerator',
52
+ 'MCPServer',
53
+ 'ToolDefinition',
54
+ 'call_tool',
55
+ 'CodeIndexer',
56
+ 'CodeSymbol',
57
+ 'CodeFile',
58
+ ]
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,371 @@
1
+ """Subagent system for autonomous task execution"""
2
+
3
+ from typing import Dict, Any, Optional, List, Callable
4
+ from dataclasses import dataclass, field
5
+ from enum import Enum
6
+ import asyncio
7
+ import subprocess
8
+ import json
9
+ import re
10
+ import uuid
11
+ from pathlib import Path # <-- Add this import
12
+
13
+ class AgentStatus(Enum):
14
+ IDLE = "idle"
15
+ RUNNING = "running"
16
+ COMPLETED = "completed"
17
+ FAILED = "failed"
18
+ WAITING = "waiting"
19
+
20
+ @dataclass
21
+ class AgentTask:
22
+ """A task for a subagent"""
23
+ id: str
24
+ description: str
25
+ parameters: Dict[str, Any] = field(default_factory=dict)
26
+ status: AgentStatus = AgentStatus.IDLE
27
+ result: Optional[Dict[str, Any]] = None
28
+ error: Optional[str] = None
29
+
30
+ class SubAgent:
31
+ """Base class for subagents"""
32
+
33
+ def __init__(self, name: str, description: str, capabilities: List[str]):
34
+ self.name = name
35
+ self.description = description
36
+ self.capabilities = capabilities
37
+ self.current_task: Optional[AgentTask] = None
38
+
39
+ async def execute(self, task: AgentTask) -> Dict[str, Any]:
40
+ """Execute a task - to be overridden"""
41
+ raise NotImplementedError
42
+
43
+ def can_handle(self, task_description: str) -> bool:
44
+ """Check if agent can handle this task"""
45
+ task_lower = task_description.lower()
46
+ for cap in self.capabilities:
47
+ if cap.lower() in task_lower:
48
+ return True
49
+ return False
50
+
51
+ class CodeAnalyzer(SubAgent):
52
+ """Analyzes code structure and quality"""
53
+
54
+ def __init__(self):
55
+ super().__init__(
56
+ name="code_analyzer",
57
+ description="Analyzes code for patterns, issues, and complexity",
58
+ capabilities=["analyze", "inspect", "complexity", "quality", "metrics"]
59
+ )
60
+
61
+ async def execute(self, task: AgentTask) -> Dict[str, Any]:
62
+ """Analyze code based on task parameters"""
63
+ file_path = task.parameters.get('file')
64
+ if not file_path:
65
+ return {"error": "No file specified"}
66
+
67
+ try:
68
+ with open(file_path, 'r') as f:
69
+ content = f.read()
70
+
71
+ lines = content.split('\n')
72
+ result = {
73
+ "file": file_path,
74
+ "lines": len(lines),
75
+ "characters": len(content),
76
+ "functions": self._count_functions(content),
77
+ "classes": self._count_classes(content),
78
+ "imports": self._count_imports(content),
79
+ "complexity_score": self._calculate_complexity(content),
80
+ "issues": self._find_issues(content),
81
+ }
82
+ return result
83
+ except Exception as e:
84
+ return {"error": str(e)}
85
+
86
+ def _count_functions(self, content: str) -> int:
87
+ return len(re.findall(r'^\s*def\s+\w+\s*\(', content, re.MULTILINE))
88
+
89
+ def _count_classes(self, content: str) -> int:
90
+ return len(re.findall(r'^\s*class\s+\w+', content, re.MULTILINE))
91
+
92
+ def _count_imports(self, content: str) -> int:
93
+ return len(re.findall(r'^\s*(?:from|import)\s+\w+', content, re.MULTILINE))
94
+
95
+ def _calculate_complexity(self, content: str) -> float:
96
+ lines = content.split('\n')
97
+ complexity = 1
98
+ keywords = ['if', 'elif', 'else', 'for', 'while', 'except', 'case', 'switch', '?']
99
+ for line in lines:
100
+ if any(k in line for k in keywords):
101
+ complexity += 1
102
+ return complexity
103
+
104
+ def _find_issues(self, content: str) -> List[Dict[str, Any]]:
105
+ issues = []
106
+ lines = content.split('\n')
107
+
108
+ for i, line in enumerate(lines, 1):
109
+ if len(line) > 120:
110
+ issues.append({
111
+ "line": i,
112
+ "type": "line_too_long",
113
+ "message": f"Line {i} exceeds 120 characters ({len(line)})"
114
+ })
115
+ if line.strip().startswith('#') and 'TODO' in line:
116
+ issues.append({
117
+ "line": i,
118
+ "type": "todo",
119
+ "message": f"TODO: {line.strip()}"
120
+ })
121
+ if line.strip().startswith('#') and 'FIXME' in line:
122
+ issues.append({
123
+ "line": i,
124
+ "type": "fixme",
125
+ "message": f"FIXME: {line.strip()}"
126
+ })
127
+
128
+ return issues
129
+
130
+ class TestGenerator(SubAgent):
131
+ """Generates tests for code"""
132
+
133
+ def __init__(self):
134
+ super().__init__(
135
+ name="test_generator",
136
+ description="Generates unit tests for code",
137
+ capabilities=["test", "unit test", "coverage", "pytest"]
138
+ )
139
+
140
+ async def execute(self, task: AgentTask) -> Dict[str, Any]:
141
+ """Generate tests for specified file"""
142
+ file_path = task.parameters.get('file')
143
+ if not file_path:
144
+ return {"error": "No file specified"}
145
+
146
+ # In production, this would use LLM to generate tests
147
+ # For now, return a template
148
+ funcs = self._extract_functions(file_path)
149
+ return {
150
+ "file": file_path,
151
+ "functions_found": len(funcs),
152
+ "functions": funcs,
153
+ "test_file": f"test_{Path(file_path).name}",
154
+ "status": "ready_for_generation"
155
+ }
156
+
157
+ def _extract_functions(self, file_path: str) -> List[str]:
158
+ try:
159
+ with open(file_path, 'r') as f:
160
+ content = f.read()
161
+ return re.findall(r'def\s+(\w+)\s*\(', content)
162
+ except:
163
+ return []
164
+
165
+ class DocGenerator(SubAgent):
166
+ """Generates documentation for code"""
167
+
168
+ def __init__(self):
169
+ super().__init__(
170
+ name="doc_generator",
171
+ description="Generates documentation for code",
172
+ capabilities=["doc", "documentation", "comment", "docstring"]
173
+ )
174
+
175
+ async def execute(self, task: AgentTask) -> Dict[str, Any]:
176
+ """Generate documentation for specified file"""
177
+ file_path = task.parameters.get('file')
178
+ if not file_path:
179
+ return {"error": "No file specified"}
180
+
181
+ funcs = self._extract_with_docstrings(file_path)
182
+ return {
183
+ "file": file_path,
184
+ "functions": funcs,
185
+ "missing_docs": [f for f in funcs if not f.get('docstring')],
186
+ "status": "ready_for_review"
187
+ }
188
+
189
+ def _extract_with_docstrings(self, file_path: str) -> List[Dict[str, Any]]:
190
+ try:
191
+ with open(file_path, 'r') as f:
192
+ content = f.read()
193
+
194
+ results = []
195
+ lines = content.split('\n')
196
+ i = 0
197
+ while i < len(lines):
198
+ line = lines[i]
199
+ func_match = re.match(r'^\s*def\s+(\w+)\s*\(', line)
200
+ if func_match:
201
+ func_name = func_match.group(1)
202
+ docstring = None
203
+ # Look ahead for docstring
204
+ j = i + 1
205
+ while j < len(lines) and j < i + 3:
206
+ if '"""' in lines[j] or "'''" in lines[j]:
207
+ docstring = lines[j].strip()
208
+ break
209
+ j += 1
210
+ results.append({
211
+ 'name': func_name,
212
+ 'line': i + 1,
213
+ 'docstring': docstring
214
+ })
215
+ i += 1
216
+ return results
217
+ except:
218
+ return []
219
+
220
+ class DelegatedCodingAgent(SubAgent):
221
+ """Delegates its task to the real standalone agent loop (runner_local.py --
222
+ the same one `tamfis-code agent`/`exec`/`ask` and the interactive REPL
223
+ drive), instead of a local heuristic.
224
+
225
+ Unlike CodeAnalyzer/TestGenerator/DocGenerator, this one can actually act
226
+ on an arbitrary objective via the model -- calling a provider directly
227
+ and executing tools locally, with its own workspace/session. It's never
228
+ selected by AgentManager's keyword-based `get_agent()` routing
229
+ (capabilities=[]) since it's only meant to be dispatched explicitly via
230
+ `execute_tasks`.
231
+ """
232
+
233
+ def __init__(
234
+ self, *, manager, provider, model, console, workspace_root: str, session_id: int,
235
+ approval_policy: str = "ask", mode: str = "agent",
236
+ ):
237
+ super().__init__(
238
+ name="delegated_coding_agent",
239
+ description="Delegates a sub-objective to the real standalone agent loop",
240
+ capabilities=[],
241
+ )
242
+ self._manager = manager
243
+ self._provider = provider
244
+ self._model = model
245
+ self._console = console
246
+ self._workspace_root = workspace_root
247
+ self._session_id = session_id
248
+ self._approval_policy = approval_policy
249
+ self._mode = mode
250
+
251
+ async def execute(self, task: AgentTask) -> Dict[str, Any]:
252
+ from .render import StreamRenderer
253
+ from .runner_local import run_local_agent_turn
254
+
255
+ renderer = StreamRenderer(self._console)
256
+ outcome = await run_local_agent_turn(
257
+ self._manager, self._provider, self._model, [{"role": "user", "content": task.description}],
258
+ self._console, renderer,
259
+ workspace_root=self._workspace_root, session_id=self._session_id,
260
+ approval_policy=self._approval_policy, interactive=False,
261
+ read_only=self._mode in {"chat", "audit", "plan"},
262
+ )
263
+ renderer.finish()
264
+ return {"status": outcome.status, "summary": outcome.summary, "error": outcome.error}
265
+
266
+
267
+ class AgentManager:
268
+ """Manages subagents and task execution"""
269
+
270
+ def __init__(self):
271
+ self.agents: Dict[str, SubAgent] = {}
272
+ self.tasks: Dict[str, AgentTask] = {}
273
+ self._register_default_agents()
274
+
275
+ def _register_default_agents(self):
276
+ """Register default agents"""
277
+ self.register(CodeAnalyzer())
278
+ self.register(TestGenerator())
279
+ self.register(DocGenerator())
280
+
281
+ def register(self, agent: SubAgent):
282
+ """Register a subagent"""
283
+ self.agents[agent.name] = agent
284
+
285
+ def list_agents(self) -> List[Dict[str, Any]]:
286
+ """List all registered agents"""
287
+ return [
288
+ {
289
+ "name": agent.name,
290
+ "description": agent.description,
291
+ "capabilities": agent.capabilities,
292
+ "status": "ready"
293
+ }
294
+ for agent in self.agents.values()
295
+ ]
296
+
297
+ def get_agent(self, task_description: str) -> Optional[SubAgent]:
298
+ """Find an agent that can handle the task"""
299
+ for agent in self.agents.values():
300
+ if agent.can_handle(task_description):
301
+ return agent
302
+ return None
303
+
304
+ async def execute_task(self, description: str, parameters: Dict[str, Any]) -> Dict[str, Any]:
305
+ """Execute a task using the appropriate agent"""
306
+ import uuid
307
+
308
+ task = AgentTask(
309
+ id=str(uuid.uuid4())[:8],
310
+ description=description,
311
+ parameters=parameters,
312
+ status=AgentStatus.RUNNING
313
+ )
314
+
315
+ agent = self.get_agent(description)
316
+ if not agent:
317
+ task.status = AgentStatus.FAILED
318
+ task.error = "No agent can handle this task"
319
+ return {"error": task.error}
320
+
321
+ try:
322
+ agent.current_task = task
323
+ result = await agent.execute(task)
324
+ task.result = result
325
+ task.status = AgentStatus.COMPLETED
326
+ return {"task_id": task.id, "agent": agent.name, "result": result}
327
+ except Exception as e:
328
+ task.status = AgentStatus.FAILED
329
+ task.error = str(e)
330
+ return {"error": str(e), "task_id": task.id}
331
+
332
+ async def execute_tasks(
333
+ self, descriptions: List[str], *,
334
+ manager, provider, model, console, workspace_root, approval_policy: str = "ask",
335
+ mode: str = "agent", max_concurrency: int = 1,
336
+ ) -> List[Dict[str, Any]]:
337
+ """Fan out N sub-objectives concurrently (bounded by max_concurrency),
338
+ each delegated to the standalone agent loop in its own local session.
339
+
340
+ Defaults to sequential (max_concurrency=1): whether concurrent tool
341
+ execution against the same workspace is safe in every case (two
342
+ sub-tasks editing overlapping files, concurrent state.json writers
343
+ from separate local sessions) hasn't been stress-tested -- raise the
344
+ cap only once you've validated that for your own workloads.
345
+ """
346
+ from .workspace import resolve_local_workspace
347
+
348
+ semaphore = asyncio.Semaphore(max(1, max_concurrency))
349
+
350
+ async def run_one(description: str) -> Dict[str, Any]:
351
+ async with semaphore:
352
+ task = AgentTask(id=f"delegated_{uuid.uuid4().hex[:8]}", description=description, status=AgentStatus.RUNNING)
353
+ self.tasks[task.id] = task
354
+ try:
355
+ workspace = resolve_local_workspace(Path(workspace_root), discover=False)
356
+ agent = DelegatedCodingAgent(
357
+ manager=manager, provider=provider, model=model, console=console,
358
+ workspace_root=workspace.workspace_root, session_id=workspace.session_id,
359
+ approval_policy=approval_policy, mode=mode,
360
+ )
361
+ result = await agent.execute(task)
362
+ task.result = result
363
+ task.status = AgentStatus.COMPLETED if result.get("status") == "completed" else AgentStatus.FAILED
364
+ task.error = result.get("error")
365
+ except Exception as e:
366
+ result = {"error": str(e)}
367
+ task.status = AgentStatus.FAILED
368
+ task.error = str(e)
369
+ return {"task_id": task.id, "description": description, "status": task.status.value, "result": result}
370
+
371
+ return list(await asyncio.gather(*(run_one(description) for description in descriptions)))