tamfis-code 0.2.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tamfis_code/__init__.py +58 -0
- tamfis_code/__main__.py +4 -0
- tamfis_code/agents.py +371 -0
- tamfis_code/api_client.py +461 -0
- tamfis_code/cli.py +2351 -0
- tamfis_code/completion.py +137 -0
- tamfis_code/config.py +199 -0
- tamfis_code/doctor.py +173 -0
- tamfis_code/enforcer.py +318 -0
- tamfis_code/indexer.py +567 -0
- tamfis_code/instructions.py +121 -0
- tamfis_code/interactive.py +985 -0
- tamfis_code/local_chat.py +117 -0
- tamfis_code/local_tools.py +93 -0
- tamfis_code/mcp.py +538 -0
- tamfis_code/metrics.py +105 -0
- tamfis_code/providers.py +423 -0
- tamfis_code/references.py +336 -0
- tamfis_code/render.py +575 -0
- tamfis_code/runner.py +635 -0
- tamfis_code/runner_local.py +364 -0
- tamfis_code/safety.py +164 -0
- tamfis_code/screenshot.py +382 -0
- tamfis_code/sessions.py +253 -0
- tamfis_code/state.py +449 -0
- tamfis_code/tasks.py +42 -0
- tamfis_code/workspace.py +329 -0
- tamfis_code-0.2.3.dist-info/METADATA +78 -0
- tamfis_code-0.2.3.dist-info/RECORD +32 -0
- tamfis_code-0.2.3.dist-info/WHEEL +5 -0
- tamfis_code-0.2.3.dist-info/entry_points.txt +4 -0
- tamfis_code-0.2.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
"""The standalone agent loop: calls an LLM provider directly (via
|
|
2
|
+
providers.py's ProviderManager) and runs its own tool-calling loop locally,
|
|
3
|
+
with no TamfisGPT Remote Workspace backend involved at all.
|
|
4
|
+
|
|
5
|
+
Generalizes local_chat.py's run_local_turn (which already proved the basic
|
|
6
|
+
send-tools -> parse tool_calls -> execute -> append role:"tool" -> resend
|
|
7
|
+
pattern works against HF/NVIDIA NIM/OpenRouter/Ollama) into the primary,
|
|
8
|
+
full-capability loop:
|
|
9
|
+
- streaming + tool-calling combined (local_chat.py's run_local_turn has
|
|
10
|
+
tools but no streaming; stream_local_turn streams but has no tools)
|
|
11
|
+
- the full tool set via mcp.py's MCPServer (read/write/edit/execute/etc),
|
|
12
|
+
not just the four read-only tools -- gated per call through safety.py's
|
|
13
|
+
risk classifier and runner.py's existing resolve_approval_decision
|
|
14
|
+
- an open-ended round loop (a high safety-valve cap, not local_chat.py's
|
|
15
|
+
hard MAX_TOOL_ROUNDS=5) with real termination conditions
|
|
16
|
+
|
|
17
|
+
Emits the same event-dict shape StreamRenderer.handle_event already expects
|
|
18
|
+
(assistant_delta/tool_call_requested/tool_output/file_mutation/
|
|
19
|
+
approval_required/ai_task_completed/ai_task_failed) so render.py needs no
|
|
20
|
+
changes to work with a local loop instead of remote SSE events.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, Optional
|
|
29
|
+
|
|
30
|
+
from rich.console import Console
|
|
31
|
+
|
|
32
|
+
from . import state as local_state
|
|
33
|
+
from .mcp import MCPServer
|
|
34
|
+
from .providers import ProviderManager, ProviderType
|
|
35
|
+
from .render import StreamRenderer
|
|
36
|
+
from .runner import TaskOutcome, resolve_approval_decision
|
|
37
|
+
from .safety import READ_ONLY_TOOLS, classify_tool_call_risk
|
|
38
|
+
|
|
39
|
+
# A safety-valve ceiling, not a target -- local_chat.py's MAX_TOOL_ROUNDS=5
|
|
40
|
+
# was appropriate for a read-only Q&A loop; a real coding-agent task
|
|
41
|
+
# legitimately needs many more tool calls (read several files, make several
|
|
42
|
+
# edits, run tests, iterate). This exists only to guarantee termination if
|
|
43
|
+
# something is genuinely stuck in a loop, not to cap normal work.
|
|
44
|
+
MAX_AGENT_ROUNDS = 200
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _latest_user_text(messages: list[dict[str, Any]]) -> str:
|
|
48
|
+
for message in reversed(messages):
|
|
49
|
+
if message.get("role") == "user":
|
|
50
|
+
return str(message.get("content") or "").strip().lower()
|
|
51
|
+
return ""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _is_plain_conversation(messages: list[dict[str, Any]]) -> bool:
|
|
55
|
+
"""Return True for turns that cannot reasonably require repository tools.
|
|
56
|
+
|
|
57
|
+
This is intentionally conservative. It prevents greetings and basic
|
|
58
|
+
conversational prompts from sending a large function catalogue to small
|
|
59
|
+
local models such as llama3.2:3b, which may serialize invented function
|
|
60
|
+
calls into ordinary assistant text instead of emitting tool_calls.
|
|
61
|
+
"""
|
|
62
|
+
text = _latest_user_text(messages)
|
|
63
|
+
if not text:
|
|
64
|
+
return True
|
|
65
|
+
exact = {
|
|
66
|
+
"hi", "hello", "hey", "hi there", "hello there",
|
|
67
|
+
"good morning", "good afternoon", "good evening",
|
|
68
|
+
"how are you", "how are you?", "thanks", "thank you",
|
|
69
|
+
}
|
|
70
|
+
if text in exact:
|
|
71
|
+
return True
|
|
72
|
+
conversational_prefixes = (
|
|
73
|
+
"who are you", "what can you do", "tell me about yourself",
|
|
74
|
+
)
|
|
75
|
+
return text.startswith(conversational_prefixes)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class _StreamedToolCall:
|
|
80
|
+
call_id: str = ""
|
|
81
|
+
name: str = ""
|
|
82
|
+
arguments: str = ""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def _stream_one_completion(
|
|
86
|
+
client, *, model: str, messages: list[dict[str, Any]], tools: list[dict[str, Any]],
|
|
87
|
+
renderer: StreamRenderer,
|
|
88
|
+
) -> tuple[str, list[_StreamedToolCall]]:
|
|
89
|
+
"""Stream one chat-completions call, forwarding text deltas to the
|
|
90
|
+
renderer as they arrive and accumulating any tool_calls deltas by index
|
|
91
|
+
(the standard OpenAI-compatible streaming tool-call pattern: id/name
|
|
92
|
+
only arrive in the first delta for a given index, arguments arrive as
|
|
93
|
+
incremental string fragments across many deltas)."""
|
|
94
|
+
content_parts: list[str] = []
|
|
95
|
+
tool_calls_by_index: dict[int, _StreamedToolCall] = {}
|
|
96
|
+
|
|
97
|
+
request_kwargs: dict[str, Any] = {
|
|
98
|
+
"model": model,
|
|
99
|
+
"messages": messages,
|
|
100
|
+
"stream": True,
|
|
101
|
+
"temperature": 0.2,
|
|
102
|
+
"max_tokens": 4096,
|
|
103
|
+
}
|
|
104
|
+
# Do not send an empty tools array. Some OpenAI-compatible servers and
|
|
105
|
+
# small local models behave differently merely because tool mode is
|
|
106
|
+
# present, even when no tool is useful for the current turn.
|
|
107
|
+
if tools:
|
|
108
|
+
request_kwargs["tools"] = tools
|
|
109
|
+
request_kwargs["tool_choice"] = "auto"
|
|
110
|
+
|
|
111
|
+
stream = await client.chat.completions.create(**request_kwargs)
|
|
112
|
+
async for chunk in stream:
|
|
113
|
+
if not chunk.choices:
|
|
114
|
+
continue
|
|
115
|
+
delta = chunk.choices[0].delta
|
|
116
|
+
if getattr(delta, "content", None):
|
|
117
|
+
content_parts.append(delta.content)
|
|
118
|
+
renderer.handle_event({"event_type": "assistant_delta", "payload": {"content": delta.content}})
|
|
119
|
+
for tc_delta in getattr(delta, "tool_calls", None) or []:
|
|
120
|
+
slot = tool_calls_by_index.setdefault(tc_delta.index, _StreamedToolCall())
|
|
121
|
+
if tc_delta.id:
|
|
122
|
+
slot.call_id = tc_delta.id
|
|
123
|
+
fn = getattr(tc_delta, "function", None)
|
|
124
|
+
if fn is not None:
|
|
125
|
+
if fn.name:
|
|
126
|
+
slot.name = fn.name
|
|
127
|
+
if fn.arguments:
|
|
128
|
+
slot.arguments += fn.arguments
|
|
129
|
+
|
|
130
|
+
ordered_calls = [tool_calls_by_index[i] for i in sorted(tool_calls_by_index)]
|
|
131
|
+
return "".join(content_parts), ordered_calls
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def run_local_agent_turn(
|
|
135
|
+
manager: ProviderManager,
|
|
136
|
+
provider: ProviderType,
|
|
137
|
+
model: Optional[str],
|
|
138
|
+
messages: list[dict[str, Any]],
|
|
139
|
+
console: Console,
|
|
140
|
+
renderer: StreamRenderer,
|
|
141
|
+
*,
|
|
142
|
+
workspace_root: str,
|
|
143
|
+
session_id: int,
|
|
144
|
+
approval_policy: str = "ask",
|
|
145
|
+
interactive: bool = True,
|
|
146
|
+
max_rounds: int = MAX_AGENT_ROUNDS,
|
|
147
|
+
read_only: bool = False,
|
|
148
|
+
) -> TaskOutcome:
|
|
149
|
+
"""Run one user turn to completion against a directly-called provider,
|
|
150
|
+
executing tool calls locally (full tool set, approval-gated) instead of
|
|
151
|
+
delegating to a Remote Workspace backend. Mirrors run_ai_task_and_stream's
|
|
152
|
+
contract (same TaskOutcome shape) so cli.py/interactive.py can drive
|
|
153
|
+
either the local or (while it still exists) remote path interchangeably.
|
|
154
|
+
|
|
155
|
+
`read_only=True` restricts both the tool schema offered to the model AND
|
|
156
|
+
(defense in depth, in case a model requests a tool it wasn't offered)
|
|
157
|
+
execution itself to safety.READ_ONLY_TOOLS -- used for chat/audit/plan
|
|
158
|
+
modes, which must never mutate the workspace.
|
|
159
|
+
"""
|
|
160
|
+
# Emit lifecycle events before any provider/network operation. Without
|
|
161
|
+
# these, the Rich live panel remains at its constructor default (idle)
|
|
162
|
+
# while get_client()/chat.completions.create() is resolving or waiting.
|
|
163
|
+
renderer.handle_event({"event_type": "task_started", "payload": {"mode": "local"}})
|
|
164
|
+
renderer.handle_event({"event_type": "context_loading", "payload": {"workspace_root": workspace_root}})
|
|
165
|
+
|
|
166
|
+
mcp_server = MCPServer(workspace_root=workspace_root, session_id=session_id)
|
|
167
|
+
if _is_plain_conversation(messages):
|
|
168
|
+
tools: list[dict[str, Any]] = []
|
|
169
|
+
elif read_only:
|
|
170
|
+
tools = mcp_server.tool_schemas_openai(names=list(READ_ONLY_TOOLS))
|
|
171
|
+
else:
|
|
172
|
+
tools = mcp_server.tool_schemas_openai()
|
|
173
|
+
|
|
174
|
+
# Plain conversation must not carry the full repository prompt. Besides
|
|
175
|
+
# wasting context, small local models can close an OpenAI-compatible
|
|
176
|
+
# stream without producing visible text when presented with a large coding
|
|
177
|
+
# system prompt for a trivial greeting. Repository-aware turns still get
|
|
178
|
+
# the complete workspace prompt (git branch/dirty status, instruction file
|
|
179
|
+
# contents -- replaces the context tamgpt6 used to assemble server-side).
|
|
180
|
+
if _is_plain_conversation(messages):
|
|
181
|
+
system_prompt = (
|
|
182
|
+
"You are TamfisGPT Code. Respond naturally and concisely to the "
|
|
183
|
+
"user. Do not invent or call tools for ordinary conversation."
|
|
184
|
+
)
|
|
185
|
+
else:
|
|
186
|
+
from .workspace import build_system_prompt
|
|
187
|
+
system_prompt = build_system_prompt(session_id, Path(workspace_root))
|
|
188
|
+
working_messages = [{"role": "system", "content": system_prompt}, *messages]
|
|
189
|
+
session_approved_risks: set[str] = set()
|
|
190
|
+
renderer.handle_event({"event_type": "context_reused", "payload": {"workspace_root": workspace_root}})
|
|
191
|
+
|
|
192
|
+
for _round in range(max_rounds):
|
|
193
|
+
renderer.handle_event({"event_type": "routing_started", "payload": {"requested_provider": provider.value}})
|
|
194
|
+
client = manager.get_client(provider)
|
|
195
|
+
if not client:
|
|
196
|
+
renderer.handle_event({"event_type": "ai_task_failed", "payload": {"error": f"Provider {provider.value} is not available (no client / no valid credentials)."}})
|
|
197
|
+
return TaskOutcome(status="failed", error=f"Provider {provider.value} is not available (no client / no valid credentials).")
|
|
198
|
+
|
|
199
|
+
resolved_provider = provider if provider != ProviderType.AUTO else manager._select_best_provider()
|
|
200
|
+
config = manager.PROVIDERS[resolved_provider]
|
|
201
|
+
resolved_model = model or config.default_model
|
|
202
|
+
renderer.handle_event({
|
|
203
|
+
"event_type": "model_selected",
|
|
204
|
+
"payload": {"provider": resolved_provider.value, "model": resolved_model},
|
|
205
|
+
})
|
|
206
|
+
renderer.handle_event({
|
|
207
|
+
"event_type": "provider_request_started",
|
|
208
|
+
"payload": {"provider": resolved_provider.value, "model": resolved_model, "round": _round + 1},
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
content, tool_calls = await _stream_one_completion(
|
|
213
|
+
client, model=resolved_model, messages=working_messages, tools=tools, renderer=renderer,
|
|
214
|
+
)
|
|
215
|
+
except Exception as exc:
|
|
216
|
+
renderer.handle_event({"event_type": "ai_task_failed", "payload": {"error": str(exc)}})
|
|
217
|
+
return TaskOutcome(status="failed", error=str(exc))
|
|
218
|
+
|
|
219
|
+
if not tool_calls:
|
|
220
|
+
# Some OpenAI-compatible servers occasionally terminate a streamed
|
|
221
|
+
# response with no content even though the same request succeeds
|
|
222
|
+
# non-streaming. Never silently report completion with a blank
|
|
223
|
+
# answer: retry once without streaming and render that canonical
|
|
224
|
+
# response. This also gives a clear error if the provider returned
|
|
225
|
+
# neither text nor tool calls.
|
|
226
|
+
if not content.strip():
|
|
227
|
+
fallback_kwargs: dict[str, Any] = {
|
|
228
|
+
"model": resolved_model,
|
|
229
|
+
"messages": working_messages,
|
|
230
|
+
"stream": False,
|
|
231
|
+
"temperature": 0.2,
|
|
232
|
+
"max_tokens": 4096,
|
|
233
|
+
}
|
|
234
|
+
if tools:
|
|
235
|
+
fallback_kwargs["tools"] = tools
|
|
236
|
+
fallback_kwargs["tool_choice"] = "auto"
|
|
237
|
+
try:
|
|
238
|
+
response = await client.chat.completions.create(**fallback_kwargs)
|
|
239
|
+
message = response.choices[0].message if response.choices else None
|
|
240
|
+
fallback_content = str(getattr(message, "content", None) or "")
|
|
241
|
+
if fallback_content:
|
|
242
|
+
content = fallback_content
|
|
243
|
+
renderer.handle_event({
|
|
244
|
+
"event_type": "assistant_delta",
|
|
245
|
+
"payload": {"content": fallback_content},
|
|
246
|
+
})
|
|
247
|
+
except Exception as exc:
|
|
248
|
+
renderer.handle_event({
|
|
249
|
+
"event_type": "ai_task_failed",
|
|
250
|
+
"payload": {"error": f"Provider returned an empty stream; fallback failed: {exc}"},
|
|
251
|
+
})
|
|
252
|
+
return TaskOutcome(
|
|
253
|
+
status="failed",
|
|
254
|
+
error=f"Provider returned an empty stream; fallback failed: {exc}",
|
|
255
|
+
)
|
|
256
|
+
if not content.strip():
|
|
257
|
+
error = "Provider completed without assistant text or tool calls."
|
|
258
|
+
renderer.handle_event({"event_type": "ai_task_failed", "payload": {"error": error}})
|
|
259
|
+
return TaskOutcome(status="failed", error=error)
|
|
260
|
+
renderer.handle_event({"event_type": "ai_task_completed", "payload": {"status": "completed"}})
|
|
261
|
+
return TaskOutcome(status="completed", summary=content)
|
|
262
|
+
|
|
263
|
+
working_messages.append({
|
|
264
|
+
"role": "assistant", "content": content or "",
|
|
265
|
+
"tool_calls": [
|
|
266
|
+
{"id": tc.call_id, "type": "function", "function": {"name": tc.name, "arguments": tc.arguments}}
|
|
267
|
+
for tc in tool_calls
|
|
268
|
+
],
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
for tc in tool_calls:
|
|
272
|
+
try:
|
|
273
|
+
arguments = json.loads(tc.arguments or "{}")
|
|
274
|
+
except json.JSONDecodeError:
|
|
275
|
+
arguments = {}
|
|
276
|
+
|
|
277
|
+
risk = classify_tool_call_risk(tc.name, arguments, workspace_root=workspace_root)
|
|
278
|
+
renderer.handle_event({"event_type": "tool_call_requested", "payload": {"name": tc.name, "arguments": arguments}})
|
|
279
|
+
|
|
280
|
+
if read_only and tc.name not in READ_ONLY_TOOLS:
|
|
281
|
+
result = {"error": f"'{tc.name}' is not available in read-only mode.", "success": False}
|
|
282
|
+
working_messages.append({"role": "tool", "tool_call_id": tc.call_id, "content": json.dumps(result)})
|
|
283
|
+
renderer.handle_event({"event_type": "tool_output", "payload": {"tool": tc.name, "result": result}})
|
|
284
|
+
continue
|
|
285
|
+
|
|
286
|
+
if risk != "read_only" and risk not in session_approved_risks:
|
|
287
|
+
renderer.handle_event({
|
|
288
|
+
"event_type": "approval_required",
|
|
289
|
+
"payload": {
|
|
290
|
+
"command": f"{tc.name}({json.dumps(arguments, default=str)})", "risk_level": risk,
|
|
291
|
+
"working_directory": workspace_root, "reason": "The agent requested this command.",
|
|
292
|
+
},
|
|
293
|
+
})
|
|
294
|
+
decision = resolve_approval_decision(console, f"{tc.name}({json.dumps(arguments, default=str)})", risk, approval_policy, interactive)
|
|
295
|
+
if decision == "approve_session":
|
|
296
|
+
session_approved_risks.add(risk)
|
|
297
|
+
elif decision == "deny":
|
|
298
|
+
result = {"error": "Denied by approval policy -- try a different, less risky approach.", "success": False}
|
|
299
|
+
working_messages.append({"role": "tool", "tool_call_id": tc.call_id, "content": json.dumps(result)})
|
|
300
|
+
renderer.handle_event({"event_type": "tool_output", "payload": {"tool": tc.name, "result": result}})
|
|
301
|
+
continue
|
|
302
|
+
|
|
303
|
+
result = await mcp_server.call_tool(tc.name, arguments)
|
|
304
|
+
renderer.handle_event({"event_type": "tool_output", "payload": {"tool": tc.name, "result": result}})
|
|
305
|
+
working_messages.append({"role": "tool", "tool_call_id": tc.call_id, "content": json.dumps(result, default=str)})
|
|
306
|
+
|
|
307
|
+
if tc.name in {"write_file", "edit_file"} and result.get("success"):
|
|
308
|
+
state = local_state.get_session_state(session_id)
|
|
309
|
+
if state.modified_files:
|
|
310
|
+
mutation = state.modified_files[-1]
|
|
311
|
+
renderer.handle_event({"event_type": "file_mutation", "payload": {
|
|
312
|
+
"path": mutation["path"], "lines_added": mutation["lines_added"],
|
|
313
|
+
"lines_removed": mutation["lines_removed"], "mutation_id": mutation["mutation_id"],
|
|
314
|
+
}})
|
|
315
|
+
|
|
316
|
+
message = f"(Stopped after {max_rounds} tool-call rounds without a final answer -- this usually means the task needs to be narrowed.)"
|
|
317
|
+
renderer.handle_event({"event_type": "ai_task_failed", "payload": {"error": message}})
|
|
318
|
+
return TaskOutcome(status="failed", error=message)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
async def run_local_shell_command(
|
|
322
|
+
console: Console, *, workspace_root: str, session_id: int, command: str,
|
|
323
|
+
approval_policy: str, interactive: bool,
|
|
324
|
+
) -> TaskOutcome:
|
|
325
|
+
"""Standalone equivalent of runner.py's run_shell_command -- executes an
|
|
326
|
+
explicit `$ <command>` / `/run` / `/shell` REPL command locally via
|
|
327
|
+
MCPServer's execute_command tool, gated through the same local risk
|
|
328
|
+
classifier and approval flow as any other tool call, instead of
|
|
329
|
+
submitting it to a Remote command queue."""
|
|
330
|
+
from .safety import classify_command_risk
|
|
331
|
+
|
|
332
|
+
action = local_state.start_action(
|
|
333
|
+
session_id, action_type="shell_command", purpose="Run an explicit local shell command",
|
|
334
|
+
risk="policy_classified", detail=command,
|
|
335
|
+
)
|
|
336
|
+
console.print(f"[bold]$[/bold] {command}")
|
|
337
|
+
|
|
338
|
+
risk = classify_command_risk(command)
|
|
339
|
+
if risk != "read_only":
|
|
340
|
+
decision = resolve_approval_decision(console, command, risk, approval_policy, interactive)
|
|
341
|
+
if decision == "deny":
|
|
342
|
+
local_state.finish_action(session_id, action.id, status="failed", summary="denied")
|
|
343
|
+
console.print("[dim]Denied.[/dim]")
|
|
344
|
+
return TaskOutcome(status="denied", error="Denied by approval policy")
|
|
345
|
+
|
|
346
|
+
mcp_server = MCPServer(workspace_root=workspace_root, session_id=session_id)
|
|
347
|
+
result = await mcp_server.call_tool("execute_command", {"command": command})
|
|
348
|
+
payload = result.get("result") if isinstance(result.get("result"), dict) else result
|
|
349
|
+
stdout = str(payload.get("stdout") or "")
|
|
350
|
+
stderr = str(payload.get("stderr") or "")
|
|
351
|
+
exit_code = payload.get("return_code")
|
|
352
|
+
ok = bool(result.get("success")) and exit_code == 0
|
|
353
|
+
body = stdout.strip()
|
|
354
|
+
if stderr.strip():
|
|
355
|
+
body = (body + "\n" + stderr.strip()).strip()
|
|
356
|
+
if not body:
|
|
357
|
+
body = "Command completed successfully with no output" if ok else f"Command failed with exit code {exit_code}"
|
|
358
|
+
from rich.panel import Panel
|
|
359
|
+
console.print(Panel(body, title=f"exit {exit_code}", border_style="green" if ok else "red"))
|
|
360
|
+
|
|
361
|
+
outcome = TaskOutcome(status="completed", summary=stdout) if ok else TaskOutcome(status="failed", error=stderr or f"exit code {exit_code}")
|
|
362
|
+
local_state.finish_action(session_id, action.id, status=outcome.status, summary=f"exit={exit_code}")
|
|
363
|
+
local_state.checkpoint(session_id, reason=f"command_{outcome.status}", summary=f"exit={exit_code}")
|
|
364
|
+
return outcome
|
tamfis_code/safety.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Local risk classification and mutation-ledger recording for the
|
|
2
|
+
standalone agent loop.
|
|
3
|
+
|
|
4
|
+
Before this module existed, risk classification, approval gating, and the
|
|
5
|
+
file-mutation ledger all lived server-side in the TamfisGPT Remote Workspace
|
|
6
|
+
backend (tamgpt6) -- this CLI only ever *rendered* a server-supplied
|
|
7
|
+
`risk_level` and *responded to* a server-emitted `approval_required` event
|
|
8
|
+
(see runner.py's `resolve_approval_decision`, which is a pure policy-vs-risk
|
|
9
|
+
decision table and was never a classifier). Now that tamfis-code runs its
|
|
10
|
+
own agent loop with no remote backend behind it, something has to take over
|
|
11
|
+
both of those jobs locally -- that's what this module is for.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import difflib
|
|
17
|
+
import re
|
|
18
|
+
import uuid
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Optional
|
|
22
|
+
|
|
23
|
+
from . import state as local_state
|
|
24
|
+
|
|
25
|
+
RISK_READ_ONLY = "read_only"
|
|
26
|
+
RISK_MEDIUM = "medium"
|
|
27
|
+
RISK_DANGEROUS = "dangerous"
|
|
28
|
+
|
|
29
|
+
READ_ONLY_TOOLS = {"read_file", "list_directory", "search_code", "get_git_info"}
|
|
30
|
+
MUTATING_FILE_TOOLS = {"write_file", "edit_file"}
|
|
31
|
+
|
|
32
|
+
MAX_MUTATION_HISTORY = 200
|
|
33
|
+
|
|
34
|
+
# Patterns that make a shell command dangerous regardless of approval_policy
|
|
35
|
+
# leniency -- destructive, irreversible, or credential-exposing. This is a
|
|
36
|
+
# heuristic allowlist-of-concerns, not a sandbox: it reduces the chance of an
|
|
37
|
+
# unreviewed catastrophic command slipping through "safe"/"accept-edits"
|
|
38
|
+
# policies, it does not replace real sandboxing (out of scope here, see the
|
|
39
|
+
# rebuild plan's explicit-non-goals section).
|
|
40
|
+
_DANGEROUS_COMMAND_PATTERNS = [
|
|
41
|
+
re.compile(r"\brm\s+(-\w*r\w*f\w*|-\w*f\w*r\w*)\b"), # rm -rf / rm -fr and letter-order variants
|
|
42
|
+
re.compile(r"\bgit\s+push\b[^\n]*--force\b"),
|
|
43
|
+
re.compile(r"\bgit\s+reset\s+--hard\b"),
|
|
44
|
+
re.compile(r"\bgit\s+clean\s+-\w*f\w*d?\b"),
|
|
45
|
+
re.compile(r"\bsudo\b"),
|
|
46
|
+
re.compile(r"\bchmod\s+-R\s+777\b"),
|
|
47
|
+
re.compile(r"\bdd\s+if="),
|
|
48
|
+
re.compile(r"\bmkfs(\.\w+)?\b"),
|
|
49
|
+
re.compile(r":\(\)\s*\{[^}]*\}\s*;\s*:"), # classic fork bomb
|
|
50
|
+
re.compile(r"\b(curl|wget)\b[^\n]*\|\s*(sudo\s+)?(ba)?sh\b"),
|
|
51
|
+
re.compile(r">\s*/dev/sd[a-z]\b"),
|
|
52
|
+
re.compile(r"\.ssh/(id_|authorized_keys)|\.aws/credentials|(^|\s)\.env\b"),
|
|
53
|
+
re.compile(r"\bshutdown\b|\breboot\b|\bhalt\b"),
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def classify_command_risk(command: str) -> str:
|
|
58
|
+
"""Heuristic risk tier for an `execute_command` tool call."""
|
|
59
|
+
text = command or ""
|
|
60
|
+
for pattern in _DANGEROUS_COMMAND_PATTERNS:
|
|
61
|
+
if pattern.search(text):
|
|
62
|
+
return RISK_DANGEROUS
|
|
63
|
+
return RISK_MEDIUM
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def classify_path_risk(path: str, workspace_root: str) -> str:
|
|
67
|
+
"""`dangerous` if the target resolves outside workspace_root, else `medium`.
|
|
68
|
+
|
|
69
|
+
Mirrors the workspace-boundary check `mcp.py`'s `_write_file` never had
|
|
70
|
+
(see the rebuild plan's Phase 2 goal) -- a path that escapes the
|
|
71
|
+
workspace is exactly the case a local agent loop has no server-side
|
|
72
|
+
backstop for anymore.
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
candidate = Path(path)
|
|
76
|
+
if not candidate.is_absolute():
|
|
77
|
+
candidate = Path(workspace_root) / candidate
|
|
78
|
+
resolved = candidate.resolve()
|
|
79
|
+
root = Path(workspace_root).resolve()
|
|
80
|
+
except (OSError, ValueError, RuntimeError):
|
|
81
|
+
return RISK_DANGEROUS
|
|
82
|
+
if resolved != root and root not in resolved.parents:
|
|
83
|
+
return RISK_DANGEROUS
|
|
84
|
+
return RISK_MEDIUM
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def classify_tool_call_risk(name: str, arguments: dict[str, Any], *, workspace_root: str) -> str:
|
|
88
|
+
"""Single entry point the standalone loop consults before executing any
|
|
89
|
+
tool call -- feeds `runner.py`'s existing `resolve_approval_decision`
|
|
90
|
+
exactly the way a server-supplied `risk_level` used to."""
|
|
91
|
+
if name in READ_ONLY_TOOLS:
|
|
92
|
+
return RISK_READ_ONLY
|
|
93
|
+
if name in MUTATING_FILE_TOOLS:
|
|
94
|
+
path = str(arguments.get("path") or "")
|
|
95
|
+
return classify_path_risk(path, workspace_root) if path else RISK_DANGEROUS
|
|
96
|
+
if name == "execute_command":
|
|
97
|
+
return classify_command_risk(str(arguments.get("command") or ""))
|
|
98
|
+
if name == "browser":
|
|
99
|
+
return RISK_MEDIUM
|
|
100
|
+
return RISK_DANGEROUS # unknown tool name -- fail safe, never default to permissive
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _unified_diff(path: str, original_content: Optional[str], new_content: Optional[str]) -> str:
|
|
104
|
+
original_lines = (original_content or "").splitlines(keepends=True)
|
|
105
|
+
new_lines = (new_content or "").splitlines(keepends=True)
|
|
106
|
+
return "".join(difflib.unified_diff(original_lines, new_lines, fromfile=f"a/{path}", tofile=f"b/{path}"))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def record_mutation(
|
|
110
|
+
session_id: int, *, path: str, operation: str,
|
|
111
|
+
original_content: Optional[str], new_content: Optional[str],
|
|
112
|
+
) -> dict[str, Any]:
|
|
113
|
+
"""Append a local mutation-ledger entry to SessionState.modified_files.
|
|
114
|
+
|
|
115
|
+
Replaces the remote backend's ledger, which this client used to only
|
|
116
|
+
ever *observe* via `file_mutation` SSE events (see render.py/runner.py's
|
|
117
|
+
prior handling) -- now the tool handler that performs the write is the
|
|
118
|
+
one that must record it, since there's no server doing that anymore.
|
|
119
|
+
`original_content=None` means this mutation created the file (revert ==
|
|
120
|
+
delete); otherwise it's the exact pre-mutation bytes needed to restore.
|
|
121
|
+
"""
|
|
122
|
+
diff_text = _unified_diff(path, original_content, new_content)
|
|
123
|
+
added = sum(1 for line in diff_text.splitlines() if line.startswith("+") and not line.startswith("+++"))
|
|
124
|
+
removed = sum(1 for line in diff_text.splitlines() if line.startswith("-") and not line.startswith("---"))
|
|
125
|
+
|
|
126
|
+
state = local_state.get_session_state(session_id)
|
|
127
|
+
entry = {
|
|
128
|
+
"mutation_id": f"mut_{uuid.uuid4().hex[:12]}",
|
|
129
|
+
"path": path,
|
|
130
|
+
"operation": operation,
|
|
131
|
+
"lines_added": added,
|
|
132
|
+
"lines_removed": removed,
|
|
133
|
+
"unified_diff": diff_text,
|
|
134
|
+
"original_content": original_content,
|
|
135
|
+
"revert_status": "none",
|
|
136
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
137
|
+
}
|
|
138
|
+
state.modified_files = (state.modified_files + [entry])[-MAX_MUTATION_HISTORY:]
|
|
139
|
+
local_state.save_session_state(session_id, modified_files=state.modified_files)
|
|
140
|
+
return entry
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def revert_mutation(session_id: int, mutation_id: str) -> dict[str, Any]:
|
|
144
|
+
"""Restore a file to its content before the given mutation, using the
|
|
145
|
+
ledger's own stored pre-mutation snapshot -- no server round-trip."""
|
|
146
|
+
state = local_state.get_session_state(session_id)
|
|
147
|
+
entry = next((m for m in state.modified_files if m.get("mutation_id") == mutation_id), None)
|
|
148
|
+
if entry is None:
|
|
149
|
+
raise ValueError(f"No recorded mutation with id {mutation_id!r} in this session")
|
|
150
|
+
if entry.get("revert_status") == "reverted":
|
|
151
|
+
return entry
|
|
152
|
+
|
|
153
|
+
path = Path(entry["path"])
|
|
154
|
+
original_content = entry.get("original_content")
|
|
155
|
+
if original_content is None:
|
|
156
|
+
if path.exists():
|
|
157
|
+
path.unlink()
|
|
158
|
+
else:
|
|
159
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
160
|
+
path.write_text(original_content, encoding="utf-8")
|
|
161
|
+
|
|
162
|
+
entry["revert_status"] = "reverted"
|
|
163
|
+
local_state.save_session_state(session_id, modified_files=state.modified_files)
|
|
164
|
+
return entry
|