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
tamfis_code/state.py
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
"""Durable local per-session CLI state: last received event id/task id per session,
|
|
2
|
+
so `attach`/`logs --follow` can resume a stream without a full replay-from-
|
|
3
|
+
zero, and so a bare `tamfis-code agents`/`status` can show "what was I last
|
|
4
|
+
doing" without another round trip.
|
|
5
|
+
|
|
6
|
+
This is client-side bookkeeping ONLY -- the server (RemoteEvent/RemoteTask
|
|
7
|
+
tables) remains the single source of truth for everything that must survive
|
|
8
|
+
a lost or wiped local state file; losing this file just means the next
|
|
9
|
+
`attach`/`logs --follow` replays from sequence 0 instead of resuming exactly
|
|
10
|
+
where it left off, not that any task/event data is lost.
|
|
11
|
+
|
|
12
|
+
Reuses the same CONFIG_DIR credentials.json/config.toml already live in
|
|
13
|
+
(Phase 18's "current canonical equivalent" allowance), rather than
|
|
14
|
+
introducing a second state directory.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import stat
|
|
23
|
+
import sys
|
|
24
|
+
import tempfile
|
|
25
|
+
import uuid
|
|
26
|
+
from dataclasses import asdict, dataclass, field
|
|
27
|
+
from datetime import datetime, timezone
|
|
28
|
+
from typing import Any, Optional
|
|
29
|
+
|
|
30
|
+
from .config import CONFIG_DIR
|
|
31
|
+
|
|
32
|
+
STATE_PATH = CONFIG_DIR / "state.json"
|
|
33
|
+
MAX_ACTION_HISTORY = 250
|
|
34
|
+
MAX_CHECKPOINTS = 50
|
|
35
|
+
MAX_SAVED_PLANS = 50
|
|
36
|
+
|
|
37
|
+
_SECRET_PATTERNS = (
|
|
38
|
+
re.compile(r"(?i)\b(authorization\s*:\s*bearer\s+)([^\s]+)"),
|
|
39
|
+
re.compile(r"(?i)\b(password|passwd|token|access_token|refresh_token|api[_-]?key|client_secret)\s*([=:])\s*([^\s&]+)"),
|
|
40
|
+
re.compile(r"\beyJ[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b"),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _now() -> str:
|
|
45
|
+
return datetime.now(timezone.utc).isoformat()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def redact_secrets(text: str) -> str:
|
|
49
|
+
value = text
|
|
50
|
+
value = _SECRET_PATTERNS[0].sub(r"\1[REDACTED]", value)
|
|
51
|
+
value = _SECRET_PATTERNS[1].sub(r"\1\2[REDACTED]", value)
|
|
52
|
+
value = _SECRET_PATTERNS[2].sub("[REDACTED_JWT]", value)
|
|
53
|
+
return value
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _sanitize(value: Any) -> Any:
|
|
57
|
+
if isinstance(value, str):
|
|
58
|
+
return redact_secrets(value)
|
|
59
|
+
if isinstance(value, list):
|
|
60
|
+
return [_sanitize(item) for item in value]
|
|
61
|
+
if isinstance(value, dict):
|
|
62
|
+
return {key: _sanitize(item) for key, item in value.items()}
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class QueuedInstruction:
|
|
68
|
+
id: str
|
|
69
|
+
text: str
|
|
70
|
+
classification: str = "append"
|
|
71
|
+
priority: int = 100
|
|
72
|
+
status: str = "queued"
|
|
73
|
+
created_at: str = field(default_factory=_now)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class AgentAction:
|
|
78
|
+
id: str
|
|
79
|
+
type: str
|
|
80
|
+
purpose: str
|
|
81
|
+
status: str = "planned"
|
|
82
|
+
risk: str = "read_only"
|
|
83
|
+
detail: str = ""
|
|
84
|
+
depends_on: list[str] = field(default_factory=list)
|
|
85
|
+
started_at: Optional[str] = None
|
|
86
|
+
completed_at: Optional[str] = None
|
|
87
|
+
result_summary: str = ""
|
|
88
|
+
attempts: int = 0
|
|
89
|
+
last_error: str = ""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class CodePlan:
|
|
94
|
+
id: str
|
|
95
|
+
objective: str
|
|
96
|
+
content: str
|
|
97
|
+
status: str = "ready"
|
|
98
|
+
source_task_id: Optional[str] = None
|
|
99
|
+
execution_task_id: Optional[str] = None
|
|
100
|
+
created_at: str = field(default_factory=_now)
|
|
101
|
+
updated_at: str = field(default_factory=_now)
|
|
102
|
+
# Structured mirror of the server's `plan_created` event payload (a list
|
|
103
|
+
# of {"step": str, "status": str} items), plus a client-assigned `index`
|
|
104
|
+
# since the server payload carries no stable step id of its own. `content`
|
|
105
|
+
# remains the human-readable markdown fallback -- this is additive, not a
|
|
106
|
+
# replacement, so nothing that reads `content` today needs to change.
|
|
107
|
+
steps: list[dict[str, Any]] = field(default_factory=list)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass
|
|
111
|
+
class SessionState:
|
|
112
|
+
session_id: int
|
|
113
|
+
workspace_root: str = ""
|
|
114
|
+
primary_workspace: str = ""
|
|
115
|
+
allowed_workspaces: list[str] = field(default_factory=list)
|
|
116
|
+
repository_root: Optional[str] = None
|
|
117
|
+
current_working_directory: str = ""
|
|
118
|
+
active_branch: Optional[str] = None
|
|
119
|
+
last_event_id: int = 0
|
|
120
|
+
last_task_id: Optional[str] = None
|
|
121
|
+
active_task: Optional[dict[str, Any]] = None
|
|
122
|
+
current_phase: str = "idle"
|
|
123
|
+
execution_status: str = "idle"
|
|
124
|
+
inspected_files: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
125
|
+
discovered_symbols: dict[str, list[dict[str, Any]]] = field(default_factory=dict)
|
|
126
|
+
discovered_services: list[dict[str, Any]] = field(default_factory=list)
|
|
127
|
+
discovered_reports: list[dict[str, Any]] = field(default_factory=list)
|
|
128
|
+
repository_context: dict[str, Any] = field(default_factory=dict)
|
|
129
|
+
completed_actions: list[dict[str, Any]] = field(default_factory=list)
|
|
130
|
+
pending_actions: list[dict[str, Any]] = field(default_factory=list)
|
|
131
|
+
queued_user_instructions: list[dict[str, Any]] = field(default_factory=list)
|
|
132
|
+
modified_files: list[dict[str, Any]] = field(default_factory=list)
|
|
133
|
+
validation_results: list[dict[str, Any]] = field(default_factory=list)
|
|
134
|
+
unresolved_issues: list[dict[str, Any]] = field(default_factory=list)
|
|
135
|
+
running_action: Optional[dict[str, Any]] = None
|
|
136
|
+
conversation_summary: str = ""
|
|
137
|
+
context_checkpoints: list[dict[str, Any]] = field(default_factory=list)
|
|
138
|
+
saved_plans: list[dict[str, Any]] = field(default_factory=list)
|
|
139
|
+
active_plan_id: Optional[str] = None
|
|
140
|
+
discovery_fingerprint: str = ""
|
|
141
|
+
selected_model: str = "auto"
|
|
142
|
+
selected_provider: Optional[str] = None
|
|
143
|
+
updated_at: str = ""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _load_raw() -> dict[str, Any]:
|
|
147
|
+
if not STATE_PATH.is_file():
|
|
148
|
+
return {}
|
|
149
|
+
try:
|
|
150
|
+
payload = json.loads(STATE_PATH.read_text(encoding="utf-8"))
|
|
151
|
+
return payload if isinstance(payload, dict) else {}
|
|
152
|
+
except OSError as exc:
|
|
153
|
+
# A permission/ownership mismatch here silently resets every session
|
|
154
|
+
# to blank (no active_plan_id, no saved_plans, no conversation_summary)
|
|
155
|
+
# -- the CLI then looks "amnesiac", re-proposing the same plan every
|
|
156
|
+
# invocation with no memory of prior progress. Surface it instead of
|
|
157
|
+
# swallowing it so that symptom is diagnosable.
|
|
158
|
+
print(
|
|
159
|
+
f"⚠ Could not read local session state at {STATE_PATH} ({exc}). "
|
|
160
|
+
"Continuing with a blank session -- prior plan/task memory is unavailable "
|
|
161
|
+
"until this is fixed (likely an ownership/permission mismatch on "
|
|
162
|
+
f"{CONFIG_DIR}).",
|
|
163
|
+
file=sys.stderr,
|
|
164
|
+
)
|
|
165
|
+
return {}
|
|
166
|
+
except json.JSONDecodeError:
|
|
167
|
+
return {}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _save_raw(data: dict[str, Any]) -> None:
|
|
171
|
+
"""Atomically replace state so a killed CLI cannot leave invalid JSON."""
|
|
172
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
173
|
+
# Only chmod when it isn't already owner-only -- calling this unconditionally
|
|
174
|
+
# on every save raises PermissionError (uncaught, all the way up) the moment
|
|
175
|
+
# CONFIG_DIR is ever owned by a different user than the caller, which used to
|
|
176
|
+
# crash the whole CLI on its very first state write.
|
|
177
|
+
if stat.S_IMODE(os.stat(CONFIG_DIR).st_mode) != stat.S_IRWXU:
|
|
178
|
+
os.chmod(CONFIG_DIR, stat.S_IRWXU)
|
|
179
|
+
fd, temp_name = tempfile.mkstemp(prefix=".state-", suffix=".json", dir=CONFIG_DIR)
|
|
180
|
+
try:
|
|
181
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
182
|
+
json.dump(_sanitize(data), handle, indent=2, sort_keys=True)
|
|
183
|
+
handle.flush()
|
|
184
|
+
os.fsync(handle.fileno())
|
|
185
|
+
os.chmod(temp_name, stat.S_IRUSR | stat.S_IWUSR)
|
|
186
|
+
os.replace(temp_name, STATE_PATH)
|
|
187
|
+
finally:
|
|
188
|
+
try:
|
|
189
|
+
os.unlink(temp_name)
|
|
190
|
+
except FileNotFoundError:
|
|
191
|
+
pass
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def get_session_state(session_id: int) -> SessionState:
|
|
195
|
+
raw = _load_raw().get(str(session_id))
|
|
196
|
+
if not raw:
|
|
197
|
+
return SessionState(session_id=session_id)
|
|
198
|
+
allowed = set(SessionState.__dataclass_fields__)
|
|
199
|
+
values = {key: value for key, value in raw.items() if key in allowed and key != "session_id"}
|
|
200
|
+
values["last_event_id"] = int(values.get("last_event_id") or 0)
|
|
201
|
+
return SessionState(session_id=session_id, **values)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def put_session_state(state: SessionState) -> None:
|
|
205
|
+
data = _load_raw()
|
|
206
|
+
latest = data.get(str(state.session_id), {})
|
|
207
|
+
# A second `tamfis-code queue ...` process may add an instruction while
|
|
208
|
+
# the streaming process is saving an event cursor. Merge by id so the
|
|
209
|
+
# cursor write cannot erase that newly queued user input.
|
|
210
|
+
if isinstance(latest, dict):
|
|
211
|
+
merged_queue = {item.get("id"): item for item in latest.get("queued_user_instructions", []) if item.get("id")}
|
|
212
|
+
merged_queue.update({item.get("id"): item for item in state.queued_user_instructions if item.get("id")})
|
|
213
|
+
state.queued_user_instructions = sorted(
|
|
214
|
+
merged_queue.values(), key=lambda item: (int(item.get("priority", 100)), item.get("created_at", ""))
|
|
215
|
+
)
|
|
216
|
+
# Event-cursor updates can race a foreground plan save in the same
|
|
217
|
+
# way they race queued instructions. Preserve plans by id as well.
|
|
218
|
+
merged_plans = {item.get("id"): item for item in latest.get("saved_plans", []) if item.get("id")}
|
|
219
|
+
merged_plans.update({item.get("id"): item for item in state.saved_plans if item.get("id")})
|
|
220
|
+
state.saved_plans = sorted(
|
|
221
|
+
merged_plans.values(), key=lambda item: item.get("created_at", "")
|
|
222
|
+
)[-MAX_SAVED_PLANS:]
|
|
223
|
+
state.updated_at = _now()
|
|
224
|
+
data[str(state.session_id)] = asdict(state)
|
|
225
|
+
_save_raw(data)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def save_session_state(
|
|
229
|
+
session_id: int, *, workspace_root: Optional[str] = None,
|
|
230
|
+
last_event_id: Optional[int] = None, last_task_id: Optional[str] = None,
|
|
231
|
+
**updates: Any,
|
|
232
|
+
) -> None:
|
|
233
|
+
state = get_session_state(session_id)
|
|
234
|
+
if workspace_root is not None:
|
|
235
|
+
state.workspace_root = workspace_root
|
|
236
|
+
if not state.primary_workspace:
|
|
237
|
+
state.primary_workspace = workspace_root
|
|
238
|
+
if workspace_root not in state.allowed_workspaces:
|
|
239
|
+
state.allowed_workspaces.append(workspace_root)
|
|
240
|
+
if not state.current_working_directory:
|
|
241
|
+
state.current_working_directory = workspace_root
|
|
242
|
+
if last_event_id is not None:
|
|
243
|
+
# Sequence ids only ever move forward for a given session -- never
|
|
244
|
+
# regress state from a stale/out-of-order write (e.g. a slower
|
|
245
|
+
# concurrent `logs --follow` process finishing after a newer one).
|
|
246
|
+
state.last_event_id = max(int(last_event_id), int(state.last_event_id or 0))
|
|
247
|
+
if last_task_id is not None:
|
|
248
|
+
state.last_task_id = last_task_id
|
|
249
|
+
for key, value in updates.items():
|
|
250
|
+
if key in SessionState.__dataclass_fields__ and key != "session_id":
|
|
251
|
+
setattr(state, key, value)
|
|
252
|
+
put_session_state(state)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def start_action(session_id: int, *, action_type: str, purpose: str,
|
|
256
|
+
risk: str = "read_only", detail: str = "") -> AgentAction:
|
|
257
|
+
state = get_session_state(session_id)
|
|
258
|
+
action = AgentAction(
|
|
259
|
+
id=f"action_{uuid.uuid4().hex[:12]}", type=action_type, purpose=purpose,
|
|
260
|
+
risk=risk, detail=detail, status="running", started_at=_now(),
|
|
261
|
+
)
|
|
262
|
+
state.running_action = asdict(action)
|
|
263
|
+
state.pending_actions.append(asdict(action))
|
|
264
|
+
state.execution_status = "running"
|
|
265
|
+
put_session_state(state)
|
|
266
|
+
return action
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# Number of consecutive failures for the same action `purpose` before it's
|
|
270
|
+
# escalated into `unresolved_issues` -- below this, a single failure is just
|
|
271
|
+
# noise (transient network blip, etc), not yet "the agent is stuck".
|
|
272
|
+
FAILURE_ESCALATION_THRESHOLD = 2
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def finish_action(session_id: int, action_id: str, *, status: str, summary: str = "", error: str = "") -> None:
|
|
276
|
+
state = get_session_state(session_id)
|
|
277
|
+
finished = None
|
|
278
|
+
remaining = []
|
|
279
|
+
for action in state.pending_actions:
|
|
280
|
+
if action.get("id") == action_id:
|
|
281
|
+
action.update(status=status, completed_at=_now(), result_summary=summary)
|
|
282
|
+
if status == "failed":
|
|
283
|
+
action["last_error"] = error or summary
|
|
284
|
+
purpose = action.get("purpose", "")
|
|
285
|
+
prior_failures = sum(
|
|
286
|
+
1 for completed in state.completed_actions
|
|
287
|
+
if completed.get("purpose") == purpose and completed.get("status") == "failed"
|
|
288
|
+
)
|
|
289
|
+
action["attempts"] = prior_failures + 1
|
|
290
|
+
if action["attempts"] >= FAILURE_ESCALATION_THRESHOLD and not any(
|
|
291
|
+
issue.get("type") == "repeated_action_failure" and issue.get("purpose") == purpose
|
|
292
|
+
for issue in state.unresolved_issues
|
|
293
|
+
):
|
|
294
|
+
state.unresolved_issues.append({
|
|
295
|
+
"type": "repeated_action_failure", "status": "needs_attention",
|
|
296
|
+
"purpose": purpose, "attempts": action["attempts"],
|
|
297
|
+
"detail": (
|
|
298
|
+
f"'{purpose}' has failed {action['attempts']} times in a row -- "
|
|
299
|
+
"consider a different approach instead of retrying as-is."
|
|
300
|
+
),
|
|
301
|
+
})
|
|
302
|
+
finished = action
|
|
303
|
+
else:
|
|
304
|
+
remaining.append(action)
|
|
305
|
+
state.pending_actions = remaining
|
|
306
|
+
if finished:
|
|
307
|
+
state.completed_actions = (state.completed_actions + [finished])[-MAX_ACTION_HISTORY:]
|
|
308
|
+
if state.running_action and state.running_action.get("id") == action_id:
|
|
309
|
+
state.running_action = None
|
|
310
|
+
state.execution_status = "idle" if state.running_action is None else "running"
|
|
311
|
+
put_session_state(state)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def enqueue_instruction(session_id: int, text: str, *, classification: str = "append",
|
|
315
|
+
priority: int = 100) -> QueuedInstruction:
|
|
316
|
+
state = get_session_state(session_id)
|
|
317
|
+
item = QueuedInstruction(id=f"instruction_{uuid.uuid4().hex[:10]}", text=text,
|
|
318
|
+
classification=classification, priority=priority)
|
|
319
|
+
state.queued_user_instructions.append(asdict(item))
|
|
320
|
+
state.queued_user_instructions.sort(
|
|
321
|
+
key=lambda value: (int(value.get("priority", 100)), value.get("created_at", ""))
|
|
322
|
+
)
|
|
323
|
+
put_session_state(state)
|
|
324
|
+
return item
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def update_instruction(session_id: int, instruction_id: str, status: str) -> bool:
|
|
328
|
+
state = get_session_state(session_id)
|
|
329
|
+
for item in state.queued_user_instructions:
|
|
330
|
+
if item.get("id") == instruction_id:
|
|
331
|
+
item["status"] = status
|
|
332
|
+
put_session_state(state)
|
|
333
|
+
return True
|
|
334
|
+
return False
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def checkpoint(session_id: int, *, reason: str, summary: str = "") -> None:
|
|
338
|
+
state = get_session_state(session_id)
|
|
339
|
+
state.context_checkpoints = (state.context_checkpoints + [{
|
|
340
|
+
"created_at": _now(), "reason": reason, "phase": state.current_phase,
|
|
341
|
+
"task_id": state.last_task_id, "last_event_id": state.last_event_id,
|
|
342
|
+
"summary": summary,
|
|
343
|
+
}])[-MAX_CHECKPOINTS:]
|
|
344
|
+
put_session_state(state)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def save_plan(
|
|
348
|
+
session_id: int, *, objective: str, content: str,
|
|
349
|
+
source_task_id: Optional[str] = None,
|
|
350
|
+
steps: Optional[list[dict[str, Any]]] = None,
|
|
351
|
+
) -> CodePlan:
|
|
352
|
+
"""Persist a completed planning result as an executable plan."""
|
|
353
|
+
state = get_session_state(session_id)
|
|
354
|
+
plan = CodePlan(
|
|
355
|
+
id=f"plan_{uuid.uuid4().hex[:10]}",
|
|
356
|
+
objective=objective.strip(), content=content.strip(),
|
|
357
|
+
source_task_id=source_task_id,
|
|
358
|
+
steps=[
|
|
359
|
+
{**step, "index": index}
|
|
360
|
+
for index, step in enumerate(steps or [])
|
|
361
|
+
if isinstance(step, dict)
|
|
362
|
+
],
|
|
363
|
+
)
|
|
364
|
+
state.saved_plans = (state.saved_plans + [asdict(plan)])[-MAX_SAVED_PLANS:]
|
|
365
|
+
state.active_plan_id = plan.id
|
|
366
|
+
put_session_state(state)
|
|
367
|
+
return plan
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def get_plan(session_id: int, plan_id: Optional[str] = None) -> Optional[dict[str, Any]]:
|
|
371
|
+
"""Return the selected plan, accepting an exact id, unique prefix, or latest."""
|
|
372
|
+
state = get_session_state(session_id)
|
|
373
|
+
plans = state.saved_plans
|
|
374
|
+
if not plans:
|
|
375
|
+
return None
|
|
376
|
+
wanted = (plan_id or state.active_plan_id or "").strip()
|
|
377
|
+
if not wanted:
|
|
378
|
+
return plans[-1]
|
|
379
|
+
exact = next((item for item in plans if item.get("id") == wanted), None)
|
|
380
|
+
if exact:
|
|
381
|
+
return exact
|
|
382
|
+
matches = [item for item in plans if str(item.get("id", "")).startswith(wanted)]
|
|
383
|
+
return matches[0] if len(matches) == 1 else None
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def update_plan(
|
|
387
|
+
session_id: int, plan_id: str, *, status: str,
|
|
388
|
+
execution_task_id: Optional[str] = None,
|
|
389
|
+
) -> Optional[dict[str, Any]]:
|
|
390
|
+
state = get_session_state(session_id)
|
|
391
|
+
updated = None
|
|
392
|
+
for item in state.saved_plans:
|
|
393
|
+
if item.get("id") == plan_id:
|
|
394
|
+
item["status"] = status
|
|
395
|
+
item["updated_at"] = _now()
|
|
396
|
+
if execution_task_id is not None:
|
|
397
|
+
item["execution_task_id"] = execution_task_id
|
|
398
|
+
updated = item
|
|
399
|
+
break
|
|
400
|
+
if updated is not None:
|
|
401
|
+
state.active_plan_id = plan_id
|
|
402
|
+
put_session_state(state)
|
|
403
|
+
return updated
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def update_plan_steps(session_id: int, plan_id: str, items: list[dict[str, Any]]) -> Optional[dict[str, Any]]:
|
|
407
|
+
"""Persist the server's `plan_created.items` onto the matching saved plan.
|
|
408
|
+
|
|
409
|
+
Today the server only ever emits this list once per plan and never
|
|
410
|
+
re-references individual steps afterward, so `steps` reflects whatever
|
|
411
|
+
the most recent `plan_created` event said -- there's no per-step
|
|
412
|
+
completion event to key off yet.
|
|
413
|
+
"""
|
|
414
|
+
state = get_session_state(session_id)
|
|
415
|
+
updated = None
|
|
416
|
+
for item in state.saved_plans:
|
|
417
|
+
if item.get("id") == plan_id:
|
|
418
|
+
item["steps"] = [
|
|
419
|
+
{**step, "index": index}
|
|
420
|
+
for index, step in enumerate(items)
|
|
421
|
+
if isinstance(step, dict)
|
|
422
|
+
]
|
|
423
|
+
item["updated_at"] = _now()
|
|
424
|
+
updated = item
|
|
425
|
+
break
|
|
426
|
+
if updated is not None:
|
|
427
|
+
put_session_state(state)
|
|
428
|
+
return updated
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def plan_execution_objective(plan: dict[str, Any]) -> str:
|
|
432
|
+
"""Turn a reviewed saved plan into an unambiguous agent execution task."""
|
|
433
|
+
return (
|
|
434
|
+
"Execute the saved engineering plan below. Inspect the current workspace first and "
|
|
435
|
+
"adapt only where repository drift requires it. Implement the work, run proportionate "
|
|
436
|
+
"validation, and report concrete results. Do not merely restate the plan.\n\n"
|
|
437
|
+
f"Original objective:\n{plan.get('objective', '')}\n\n"
|
|
438
|
+
f"Saved plan ({plan.get('id', 'unknown')}):\n{plan.get('content', '')}"
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def all_known_session_ids() -> list[int]:
|
|
443
|
+
ids = []
|
|
444
|
+
for key in _load_raw().keys():
|
|
445
|
+
try:
|
|
446
|
+
ids.append(int(key))
|
|
447
|
+
except ValueError:
|
|
448
|
+
continue
|
|
449
|
+
return sorted(ids)
|
tamfis_code/tasks.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Task discovery for `/resume` and `/retry` -- there is no dedicated "list
|
|
2
|
+
tasks for a session" endpoint (see docs/REMOTE_AGENT_MASTER_SPEC.md Phase 21;
|
|
3
|
+
not adding one this pass to keep the shared API surface unchanged), so the
|
|
4
|
+
most-recent-task lookup is derived from GET /sessions/{id}/thread's message
|
|
5
|
+
list the same way the web SessionView.tsx reconstructs AITurns on refresh --
|
|
6
|
+
distinct task_ids in reverse-chronological order, then GET /tasks/{id} for
|
|
7
|
+
status until a match is found.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from .api_client import RemoteAPIClient
|
|
15
|
+
|
|
16
|
+
# Bounded lookback rather than scanning a session's entire history -- a
|
|
17
|
+
# session that has run hundreds of tasks should not make this many
|
|
18
|
+
# GET /tasks/{id} round trips just to find one recent failure.
|
|
19
|
+
DEFAULT_LOOKBACK = 15
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def find_recent_task(
|
|
23
|
+
client: RemoteAPIClient, session_id: int, *, only_status: Optional[set[str]] = None,
|
|
24
|
+
lookback: int = DEFAULT_LOOKBACK,
|
|
25
|
+
) -> Optional[dict]:
|
|
26
|
+
thread = await client.get_thread(session_id, after_sequence=0)
|
|
27
|
+
messages = thread.get("messages") or []
|
|
28
|
+
|
|
29
|
+
candidate_ids: list[str] = []
|
|
30
|
+
for message in reversed(messages): # thread messages are oldest-first
|
|
31
|
+
task_id = message.get("task_id")
|
|
32
|
+
if not task_id or task_id in candidate_ids:
|
|
33
|
+
continue
|
|
34
|
+
candidate_ids.append(task_id)
|
|
35
|
+
if len(candidate_ids) >= lookback:
|
|
36
|
+
break
|
|
37
|
+
|
|
38
|
+
for task_id in candidate_ids:
|
|
39
|
+
task = await client.get_task(task_id)
|
|
40
|
+
if only_status is None or str(task.get("status")) in only_status:
|
|
41
|
+
return task
|
|
42
|
+
return None
|