@team-agent/installer 0.1.3 → 0.1.7
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.
- package/README.md +0 -2
- package/README.zh.md +0 -2
- package/package.json +2 -2
- package/pyproject.toml +1 -1
- package/scripts/run_regression_tests.py +4 -0
- package/src/team_agent/__init__.py +1 -1
- package/src/team_agent/mcp_server.py +1 -1
- package/src/team_agent/message_store.py +23 -0
- package/src/team_agent/runtime.py +180 -53
- package/src/team_agent/state.py +15 -5
- package/tests/run_tests.py +5651 -0
- package/docs/README.md +0 -10
- package/docs/team-agent-foundation-and-boundaries.md +0 -292
package/README.md
CHANGED
|
@@ -83,8 +83,6 @@ Three design choices make this possible:
|
|
|
83
83
|
|
|
84
84
|
**3. Standards over inventions.** MCP for tool calls, Skill files for role definitions. Anything the broader ecosystem ships, this picks up automatically.
|
|
85
85
|
|
|
86
|
-
For the full design philosophy and boundaries, see [`docs/team-agent-foundation-and-boundaries.md`](./docs/team-agent-foundation-and-boundaries.md).
|
|
87
|
-
|
|
88
86
|
---
|
|
89
87
|
|
|
90
88
|
## Quick start
|
package/README.zh.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@team-agent/installer",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "npx installer for Team Agent",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"codex",
|
|
@@ -29,11 +29,11 @@
|
|
|
29
29
|
"npm",
|
|
30
30
|
"scripts",
|
|
31
31
|
"src",
|
|
32
|
+
"tests",
|
|
32
33
|
"skills",
|
|
33
34
|
"templates",
|
|
34
35
|
"examples",
|
|
35
36
|
"schemas",
|
|
36
|
-
"docs/team-agent-foundation-and-boundaries.md",
|
|
37
37
|
"crates/team-agent-core/Cargo.toml",
|
|
38
38
|
"crates/team-agent-core/src",
|
|
39
39
|
"pyproject.toml",
|
package/pyproject.toml
CHANGED
|
@@ -10,6 +10,9 @@ from pathlib import Path
|
|
|
10
10
|
|
|
11
11
|
REGRESSION_TESTS = [
|
|
12
12
|
"tests.run_tests.RuntimeTests.test_send_default_timeout_reports_submitted_unverified",
|
|
13
|
+
"tests.run_tests.RuntimeTests.test_message_fragment_matching_ignores_generic_header",
|
|
14
|
+
"tests.run_tests.RuntimeTests.test_wait_for_message_ready_does_not_accept_old_header",
|
|
15
|
+
"tests.run_tests.RuntimeTests.test_wait_for_message_ready_accepts_only_new_pasted_prompt",
|
|
13
16
|
"tests.run_tests.RuntimeTests.test_worker_delivery_retries_paste_until_message_ready",
|
|
14
17
|
"tests.run_tests.RuntimeTests.test_worker_pasted_content_prompt_retries_enter_until_submitted",
|
|
15
18
|
"tests.run_tests.RuntimeTests.test_worker_pasted_content_prompt_reports_unverified_when_enter_does_not_submit",
|
|
@@ -26,6 +29,7 @@ REGRESSION_TESTS = [
|
|
|
26
29
|
"tests.run_tests.RuntimeTests.test_start_agent_falls_back_to_fresh_when_resume_window_exits",
|
|
27
30
|
"tests.run_tests.RuntimeTests.test_broadcast_sends_only_to_current_team_and_excludes_sender",
|
|
28
31
|
"tests.run_tests.RuntimeTests.test_status_and_collect_expose_uncollected_report_result",
|
|
32
|
+
"tests.run_tests.RuntimeTests.test_collect_accepts_message_scoped_report_result",
|
|
29
33
|
"tests.run_tests.RuntimeTests.test_report_result_queues_leader_notification_without_blocking_mcp",
|
|
30
34
|
"tests.run_tests.RuntimeTests.test_mcp_send_message_accepts_thin_args_and_returns_compact_result",
|
|
31
35
|
"tests.run_tests.RuntimeTests.test_mcp_send_message_accepts_broadcast_target",
|
|
@@ -505,7 +505,7 @@ def handle_mcp(tools: TeamOrchestratorTools, request: dict[str, Any]) -> dict[st
|
|
|
505
505
|
"result": {
|
|
506
506
|
"protocolVersion": request.get("params", {}).get("protocolVersion", "2024-11-05"),
|
|
507
507
|
"capabilities": {"tools": {}},
|
|
508
|
-
"serverInfo": {"name": "team_orchestrator", "version": "0.1.
|
|
508
|
+
"serverInfo": {"name": "team_orchestrator", "version": "0.1.4"},
|
|
509
509
|
},
|
|
510
510
|
}
|
|
511
511
|
if method == "tools/list":
|
|
@@ -475,6 +475,29 @@ class MessageStore:
|
|
|
475
475
|
)
|
|
476
476
|
return ids
|
|
477
477
|
|
|
478
|
+
def acknowledge_message(self, message_id: str, agent_id: str) -> list[str]:
|
|
479
|
+
now = utcnow()
|
|
480
|
+
with closing(self.connect()) as conn:
|
|
481
|
+
with conn:
|
|
482
|
+
row = conn.execute(
|
|
483
|
+
"""
|
|
484
|
+
select message_id from messages
|
|
485
|
+
where message_id = ? and recipient = ? and status in ('pending', 'accepted', 'target_resolved', 'injected', 'visible', 'submitted', 'delivered')
|
|
486
|
+
""",
|
|
487
|
+
(message_id, agent_id),
|
|
488
|
+
).fetchone()
|
|
489
|
+
if not row:
|
|
490
|
+
return []
|
|
491
|
+
conn.execute(
|
|
492
|
+
"""
|
|
493
|
+
update messages
|
|
494
|
+
set status = 'acknowledged', acknowledged_at = ?, updated_at = ?
|
|
495
|
+
where message_id = ? and recipient = ? and status in ('pending', 'accepted', 'target_resolved', 'injected', 'visible', 'submitted', 'delivered')
|
|
496
|
+
""",
|
|
497
|
+
(now, now, message_id, agent_id),
|
|
498
|
+
)
|
|
499
|
+
return [message_id]
|
|
500
|
+
|
|
478
501
|
def results(self, uncollected_only: bool = False) -> list[dict[str, Any]]:
|
|
479
502
|
query = "select * from results order by created_at"
|
|
480
503
|
if uncollected_only:
|
|
@@ -35,7 +35,15 @@ from team_agent.providers import ResumeUnavailable, get_adapter, shell_command_f
|
|
|
35
35
|
from team_agent.routing import route_task
|
|
36
36
|
from team_agent.simple_yaml import dumps
|
|
37
37
|
from team_agent.spec import load_spec, validate_result_envelope, workspace_from_spec
|
|
38
|
-
from team_agent.state import
|
|
38
|
+
from team_agent.state import (
|
|
39
|
+
SESSION_CAPTURE_FIELDS,
|
|
40
|
+
SESSION_STATE_FIELDS,
|
|
41
|
+
load_runtime_state,
|
|
42
|
+
normalize_agent_session_state,
|
|
43
|
+
runtime_state_path,
|
|
44
|
+
save_runtime_state,
|
|
45
|
+
write_team_state,
|
|
46
|
+
)
|
|
39
47
|
from team_agent.task_graph import ready_tasks, update_task_status
|
|
40
48
|
from team_agent.task_graph import TASK_STATUSES
|
|
41
49
|
|
|
@@ -520,10 +528,7 @@ def _load_snapshot_state(path: Path) -> dict[str, Any] | None:
|
|
|
520
528
|
state = json.loads(path.read_text(encoding="utf-8"))
|
|
521
529
|
except (OSError, json.JSONDecodeError):
|
|
522
530
|
return None
|
|
523
|
-
|
|
524
|
-
if isinstance(agent_state, dict):
|
|
525
|
-
for field in ["session_id", "rollout_path", "captured_at", "captured_via", "attribution_confidence", "spawn_cwd"]:
|
|
526
|
-
agent_state.setdefault(field, None)
|
|
531
|
+
normalize_agent_session_state(state)
|
|
527
532
|
return state
|
|
528
533
|
|
|
529
534
|
|
|
@@ -1290,13 +1295,15 @@ def collect(workspace: Path, result_file: Path | None = None) -> dict[str, Any]:
|
|
|
1290
1295
|
store.add_result(envelope)
|
|
1291
1296
|
|
|
1292
1297
|
rows = store.results(uncollected_only=True)
|
|
1293
|
-
valid_rows: list[tuple[dict[str, Any], dict[str, Any]]] = []
|
|
1298
|
+
valid_rows: list[tuple[dict[str, Any], dict[str, Any], dict[str, Any] | None]] = []
|
|
1294
1299
|
for row in rows:
|
|
1295
1300
|
envelope: Any = None
|
|
1296
1301
|
try:
|
|
1297
1302
|
envelope = json.loads(row["envelope"])
|
|
1298
1303
|
validate_result_envelope(envelope)
|
|
1299
|
-
|
|
1304
|
+
task = _find_task_or_none(state["tasks"], envelope["task_id"])
|
|
1305
|
+
if task is None and not _is_message_scoped_result(store, envelope):
|
|
1306
|
+
raise RuntimeError(f"unknown task id: {envelope['task_id']}")
|
|
1300
1307
|
except (json.JSONDecodeError, ValidationError, RuntimeError) as exc:
|
|
1301
1308
|
invalid_results.append(
|
|
1302
1309
|
_record_invalid_result(
|
|
@@ -1308,7 +1315,7 @@ def collect(workspace: Path, result_file: Path | None = None) -> dict[str, Any]:
|
|
|
1308
1315
|
)
|
|
1309
1316
|
store.mark_result_invalid(row["result_id"], str(exc))
|
|
1310
1317
|
else:
|
|
1311
|
-
valid_rows.append((row, envelope))
|
|
1318
|
+
valid_rows.append((row, envelope, task))
|
|
1312
1319
|
|
|
1313
1320
|
if invalid_results:
|
|
1314
1321
|
save_runtime_state(workspace, state)
|
|
@@ -1326,17 +1333,20 @@ def collect(workspace: Path, result_file: Path | None = None) -> dict[str, Any]:
|
|
|
1326
1333
|
collected: list[dict[str, Any]] = []
|
|
1327
1334
|
collected_results: list[dict[str, Any]] = []
|
|
1328
1335
|
next_state = copy.deepcopy(state)
|
|
1329
|
-
for row, envelope in valid_rows:
|
|
1330
|
-
task
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1336
|
+
for row, envelope, task in valid_rows:
|
|
1337
|
+
if task is not None:
|
|
1338
|
+
next_task = _find_task(next_state["tasks"], envelope["task_id"])
|
|
1339
|
+
task_status = _result_status_to_task_status(next_task, envelope["status"])
|
|
1340
|
+
update_task_status(
|
|
1341
|
+
next_state["tasks"],
|
|
1342
|
+
envelope["task_id"],
|
|
1343
|
+
task_status,
|
|
1344
|
+
envelope.get("summary"),
|
|
1345
|
+
envelope.get("artifacts", []),
|
|
1346
|
+
)
|
|
1347
|
+
next_task["accepted_result_id"] = row["result_id"]
|
|
1348
|
+
else:
|
|
1349
|
+
task_status = "message_scoped"
|
|
1340
1350
|
collected.append(envelope)
|
|
1341
1351
|
collected_results.append(
|
|
1342
1352
|
{
|
|
@@ -1346,6 +1356,7 @@ def collect(workspace: Path, result_file: Path | None = None) -> dict[str, Any]:
|
|
|
1346
1356
|
"status": envelope["status"],
|
|
1347
1357
|
"summary": envelope.get("summary"),
|
|
1348
1358
|
"tests": envelope.get("tests", []),
|
|
1359
|
+
"scope": "task" if task is not None else "message",
|
|
1349
1360
|
}
|
|
1350
1361
|
)
|
|
1351
1362
|
event_log.write(
|
|
@@ -1354,12 +1365,13 @@ def collect(workspace: Path, result_file: Path | None = None) -> dict[str, Any]:
|
|
|
1354
1365
|
task_id=envelope["task_id"],
|
|
1355
1366
|
status=envelope["status"],
|
|
1356
1367
|
task_status=task_status,
|
|
1357
|
-
retry_count=task.get("retry_count"),
|
|
1358
|
-
retry_limit=task.get("retry_limit"),
|
|
1368
|
+
retry_count=task.get("retry_count") if task else None,
|
|
1369
|
+
retry_limit=task.get("retry_limit") if task else None,
|
|
1370
|
+
scope="task" if task is not None else "message",
|
|
1359
1371
|
)
|
|
1360
1372
|
state_path = write_team_state(workspace, spec, next_state, [{"envelope": env} for env in collected])
|
|
1361
1373
|
save_runtime_state(workspace, next_state)
|
|
1362
|
-
for row, _ in valid_rows:
|
|
1374
|
+
for row, _, _ in valid_rows:
|
|
1363
1375
|
store.mark_result_collected(row["result_id"])
|
|
1364
1376
|
return {
|
|
1365
1377
|
"ok": not invalid_results,
|
|
@@ -1377,6 +1389,8 @@ def report_result(workspace: Path, envelope: dict[str, Any]) -> dict[str, Any]:
|
|
|
1377
1389
|
store = MessageStore(workspace)
|
|
1378
1390
|
result_id = store.add_result(envelope)
|
|
1379
1391
|
acknowledged = store.acknowledge_task_messages(envelope["task_id"], envelope["agent_id"])
|
|
1392
|
+
if not acknowledged:
|
|
1393
|
+
acknowledged = store.acknowledge_message(envelope["task_id"], envelope["agent_id"])
|
|
1380
1394
|
event_log = EventLog(workspace)
|
|
1381
1395
|
notification = _notify_leader_of_report_result(workspace, envelope, result_id, event_log)
|
|
1382
1396
|
leader_notified = bool(notification.get("ok")) and notification.get("status") in {"submitted", "visible", "delivered", "acknowledged"}
|
|
@@ -1561,8 +1575,7 @@ def _capture_agent_session(
|
|
|
1561
1575
|
result = adapter.capture_session_id(agent_id, spawn_context, timeout_s=timeout_s)
|
|
1562
1576
|
if not isinstance(result, dict) or not result.get("session_id"):
|
|
1563
1577
|
return None
|
|
1564
|
-
|
|
1565
|
-
agent_state[key] = result.get(key)
|
|
1578
|
+
_copy_session_metadata(agent_state, result)
|
|
1566
1579
|
agent_state.pop("_pending_session_id", None)
|
|
1567
1580
|
event_log.write(
|
|
1568
1581
|
"session.captured",
|
|
@@ -1576,6 +1589,16 @@ def _capture_agent_session(
|
|
|
1576
1589
|
return result
|
|
1577
1590
|
|
|
1578
1591
|
|
|
1592
|
+
def _copy_session_metadata(target: dict[str, Any], source: dict[str, Any]) -> None:
|
|
1593
|
+
for key in SESSION_STATE_FIELDS:
|
|
1594
|
+
target[key] = source.get(key)
|
|
1595
|
+
|
|
1596
|
+
|
|
1597
|
+
def _clear_session_capture_fields(target: dict[str, Any]) -> None:
|
|
1598
|
+
for key in SESSION_CAPTURE_FIELDS:
|
|
1599
|
+
target[key] = None
|
|
1600
|
+
|
|
1601
|
+
|
|
1579
1602
|
def _attach_profile_resume_root(workspace: Path, command_agent: dict[str, Any], previous: dict[str, Any]) -> dict[str, Any]:
|
|
1580
1603
|
profile_launch = command_agent.get("_provider_profile") or prepare_agent_profile_launch(workspace, command_agent)
|
|
1581
1604
|
if not profile_launch:
|
|
@@ -1622,8 +1645,7 @@ def _prepare_resume_state(
|
|
|
1622
1645
|
if not repaired:
|
|
1623
1646
|
repaired = adapter.recover_session_id(agent_id, prepared, workspace, exclude_session_ids or set())
|
|
1624
1647
|
if repaired:
|
|
1625
|
-
|
|
1626
|
-
prepared[key] = repaired.get(key)
|
|
1648
|
+
_copy_session_metadata(prepared, repaired)
|
|
1627
1649
|
event_log.write(
|
|
1628
1650
|
"resume.session_repaired",
|
|
1629
1651
|
agent_id=agent_id,
|
|
@@ -1648,8 +1670,7 @@ def _prepare_resume_state(
|
|
|
1648
1670
|
f"Cannot resume agent {agent_id}: stored session {session_id} is not available. "
|
|
1649
1671
|
"Use --allow-fresh only if losing that worker context is acceptable."
|
|
1650
1672
|
)
|
|
1651
|
-
|
|
1652
|
-
prepared[key] = None
|
|
1673
|
+
_clear_session_capture_fields(prepared)
|
|
1653
1674
|
event_log.write(
|
|
1654
1675
|
"resume.session_unavailable",
|
|
1655
1676
|
agent_id=agent_id,
|
|
@@ -1963,8 +1984,7 @@ def restart(workspace: Path, allow_fresh: bool = False, team: str | None = None)
|
|
|
1963
1984
|
if profile_launch.get("claude_projects_root"):
|
|
1964
1985
|
agent_state["claude_projects_root"] = profile_launch["claude_projects_root"]
|
|
1965
1986
|
if restart_mode == "fresh":
|
|
1966
|
-
|
|
1967
|
-
agent_state[key] = None
|
|
1987
|
+
_clear_session_capture_fields(agent_state)
|
|
1968
1988
|
if command_agent.get("_session_id"):
|
|
1969
1989
|
agent_state["_pending_session_id"] = command_agent["_session_id"]
|
|
1970
1990
|
_capture_agent_session(
|
|
@@ -2222,8 +2242,7 @@ def _start_agent_unlocked(workspace: Path, agent_id: str, force: bool, open_disp
|
|
|
2222
2242
|
if profile_launch.get("claude_projects_root"):
|
|
2223
2243
|
agent_state["claude_projects_root"] = profile_launch["claude_projects_root"]
|
|
2224
2244
|
if start_mode == "fresh":
|
|
2225
|
-
|
|
2226
|
-
agent_state[key] = None
|
|
2245
|
+
_clear_session_capture_fields(agent_state)
|
|
2227
2246
|
if command_agent.get("_session_id"):
|
|
2228
2247
|
agent_state["_pending_session_id"] = command_agent["_session_id"]
|
|
2229
2248
|
_capture_agent_session(workspace, agent_id, agent_state, event_log, timeout_s=1.5, exclude_session_ids=known_session_ids)
|
|
@@ -4018,8 +4037,24 @@ def _tmux_inject_text(target: str, text: str, submit_key: str, buffer_name: str,
|
|
|
4018
4037
|
"attempts": attempt_log,
|
|
4019
4038
|
"verification": prepared["verification"],
|
|
4020
4039
|
}
|
|
4040
|
+
baseline = _capture_tmux_pane_text(target)
|
|
4041
|
+
if not baseline["ok"]:
|
|
4042
|
+
return {
|
|
4043
|
+
"ok": False,
|
|
4044
|
+
"stage": "pre-paste-capture",
|
|
4045
|
+
"error": baseline.get("error"),
|
|
4046
|
+
"attempts": attempt_log,
|
|
4047
|
+
"verification": "pre_paste_capture_failed",
|
|
4048
|
+
}
|
|
4049
|
+
baseline_capture = baseline["capture"]
|
|
4021
4050
|
if token:
|
|
4022
|
-
pre_visible, pre_verification, pre_capture = _wait_for_message_ready(
|
|
4051
|
+
pre_visible, pre_verification, pre_capture = _wait_for_message_ready(
|
|
4052
|
+
target,
|
|
4053
|
+
token,
|
|
4054
|
+
0.0,
|
|
4055
|
+
expected_text=text,
|
|
4056
|
+
allow_pasted_prompt=False,
|
|
4057
|
+
)
|
|
4023
4058
|
if pre_visible:
|
|
4024
4059
|
attempt_entry = {
|
|
4025
4060
|
"attempt": attempt,
|
|
@@ -4060,6 +4095,23 @@ def _tmux_inject_text(target: str, text: str, submit_key: str, buffer_name: str,
|
|
|
4060
4095
|
"attempts": attempt_log,
|
|
4061
4096
|
"submit_attempts": submit.get("attempts"),
|
|
4062
4097
|
}
|
|
4098
|
+
if _capture_has_pasted_content_prompt(baseline_capture):
|
|
4099
|
+
attempt_log.append(
|
|
4100
|
+
{
|
|
4101
|
+
"attempt": attempt,
|
|
4102
|
+
"visible": False,
|
|
4103
|
+
"verification": "preexisting_unverified_pasted_content_prompt",
|
|
4104
|
+
"text_bytes": text_bytes,
|
|
4105
|
+
"ready_timeout_sec": 0.0,
|
|
4106
|
+
}
|
|
4107
|
+
)
|
|
4108
|
+
return {
|
|
4109
|
+
"ok": False,
|
|
4110
|
+
"stage": "preexisting-input",
|
|
4111
|
+
"error": "target pane already has an unverified pasted-content prompt; refusing to paste again to avoid duplicate messages",
|
|
4112
|
+
"attempts": attempt_log,
|
|
4113
|
+
"verification": "preexisting_unverified_pasted_content_prompt",
|
|
4114
|
+
}
|
|
4063
4115
|
buffered = _tmux_set_buffer_text(buffer_name, text)
|
|
4064
4116
|
if not buffered["ok"]:
|
|
4065
4117
|
return {"ok": False, "stage": buffered["stage"], "error": buffered.get("error"), "attempts": attempt_log}
|
|
@@ -4068,7 +4120,13 @@ def _tmux_inject_text(target: str, text: str, submit_key: str, buffer_name: str,
|
|
|
4068
4120
|
return {"ok": False, "stage": "paste-buffer", "error": proc.stderr.strip(), "attempts": attempt_log}
|
|
4069
4121
|
time.sleep(0.25)
|
|
4070
4122
|
if token:
|
|
4071
|
-
visible, verification, capture_text = _wait_for_message_ready(
|
|
4123
|
+
visible, verification, capture_text = _wait_for_message_ready(
|
|
4124
|
+
target,
|
|
4125
|
+
token,
|
|
4126
|
+
ready_timeout,
|
|
4127
|
+
expected_text=text,
|
|
4128
|
+
baseline_capture=baseline_capture,
|
|
4129
|
+
)
|
|
4072
4130
|
else:
|
|
4073
4131
|
visible, verification, capture_text = True, "no_token", ""
|
|
4074
4132
|
last_verification = verification
|
|
@@ -4237,35 +4295,51 @@ def _wait_for_visible_token(target: str, token: str, timeout: float) -> tuple[bo
|
|
|
4237
4295
|
deadline = time.monotonic() + max(timeout, 0.0)
|
|
4238
4296
|
last = "not_checked"
|
|
4239
4297
|
while True:
|
|
4240
|
-
capture =
|
|
4241
|
-
if capture
|
|
4242
|
-
if token in capture
|
|
4298
|
+
capture = _capture_tmux_pane_text(target)
|
|
4299
|
+
if capture["ok"]:
|
|
4300
|
+
if token in capture["capture"] or f"[team-agent-token:{token}]" in capture["capture"]:
|
|
4243
4301
|
return True, "capture_contains_token"
|
|
4244
4302
|
last = "capture_missing_token"
|
|
4245
4303
|
else:
|
|
4246
|
-
last = f"capture_failed: {capture.
|
|
4304
|
+
last = f"capture_failed: {capture.get('error')}"
|
|
4247
4305
|
if time.monotonic() >= deadline:
|
|
4248
4306
|
return False, last
|
|
4249
4307
|
time.sleep(0.1)
|
|
4250
4308
|
|
|
4251
4309
|
|
|
4252
|
-
def
|
|
4310
|
+
def _capture_tmux_pane_text(target: str) -> dict[str, Any]:
|
|
4311
|
+
capture = run_cmd(["tmux", "capture-pane", "-p", "-S", f"-{DELIVERY_CAPTURE_LINES}", "-t", target], timeout=5)
|
|
4312
|
+
if capture.returncode != 0:
|
|
4313
|
+
return {"ok": False, "capture": "", "error": capture.stderr.strip() or "tmux capture-pane failed"}
|
|
4314
|
+
return {"ok": True, "capture": capture.stdout}
|
|
4315
|
+
|
|
4316
|
+
|
|
4317
|
+
def _wait_for_message_ready(
|
|
4318
|
+
target: str,
|
|
4319
|
+
message_id: str,
|
|
4320
|
+
timeout: float,
|
|
4321
|
+
expected_text: str = "",
|
|
4322
|
+
allow_pasted_prompt: bool = True,
|
|
4323
|
+
baseline_capture: str = "",
|
|
4324
|
+
) -> tuple[bool, str, str]:
|
|
4253
4325
|
deadline = time.monotonic() + max(timeout, 0.0)
|
|
4254
4326
|
last = "not_checked"
|
|
4255
4327
|
last_capture = ""
|
|
4328
|
+
baseline_had_pasted_prompt = _capture_has_pasted_content_prompt(baseline_capture)
|
|
4256
4329
|
while True:
|
|
4257
|
-
capture =
|
|
4258
|
-
if capture
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4330
|
+
capture = _capture_tmux_pane_text(target)
|
|
4331
|
+
if capture["ok"]:
|
|
4332
|
+
capture_text = capture["capture"]
|
|
4333
|
+
last_capture = capture_text
|
|
4334
|
+
if message_id in capture_text or f"[team-agent-token:{message_id}]" in capture_text:
|
|
4335
|
+
return True, "capture_contains_token", capture_text
|
|
4336
|
+
if expected_text and _capture_contains_message_fragment(capture_text, expected_text):
|
|
4337
|
+
return True, "capture_contains_message_fragment", capture_text
|
|
4338
|
+
if allow_pasted_prompt and _capture_has_pasted_content_prompt(capture_text) and not baseline_had_pasted_prompt:
|
|
4339
|
+
return True, "capture_contains_new_pasted_content_prompt", capture_text
|
|
4266
4340
|
last = "capture_missing_token"
|
|
4267
4341
|
else:
|
|
4268
|
-
last = f"capture_failed: {capture.
|
|
4342
|
+
last = f"capture_failed: {capture.get('error')}"
|
|
4269
4343
|
if time.monotonic() >= deadline:
|
|
4270
4344
|
return False, last, last_capture
|
|
4271
4345
|
time.sleep(0.1)
|
|
@@ -4299,15 +4373,18 @@ def _capture_contains_message_fragment(capture_text: str, expected_text: str) ->
|
|
|
4299
4373
|
haystack = _compact_visible_text(capture_text)
|
|
4300
4374
|
if not haystack:
|
|
4301
4375
|
return False
|
|
4302
|
-
|
|
4376
|
+
fragments = _message_fragment_candidates(expected_text)
|
|
4377
|
+
if not fragments:
|
|
4378
|
+
return False
|
|
4379
|
+
return any(fragment in haystack for fragment in fragments)
|
|
4303
4380
|
|
|
4304
4381
|
|
|
4305
4382
|
def _message_fragment_candidates(text: str) -> list[str]:
|
|
4306
4383
|
sanitized = re.sub(r"\[team-agent-token:[^\]]+\]", "", text)
|
|
4307
4384
|
fragments: list[str] = []
|
|
4308
|
-
for line in sanitized
|
|
4385
|
+
for line in _message_content_lines(sanitized):
|
|
4309
4386
|
compact = _compact_visible_text(line)
|
|
4310
|
-
if
|
|
4387
|
+
if not _is_strong_message_fragment(compact):
|
|
4311
4388
|
continue
|
|
4312
4389
|
if len(compact) <= 72:
|
|
4313
4390
|
fragments.append(compact)
|
|
@@ -4330,6 +4407,35 @@ def _message_fragment_candidates(text: str) -> list[str]:
|
|
|
4330
4407
|
return unique
|
|
4331
4408
|
|
|
4332
4409
|
|
|
4410
|
+
def _message_content_lines(text: str) -> list[str]:
|
|
4411
|
+
lines = text.splitlines()
|
|
4412
|
+
if lines and lines[0].strip().startswith("Team Agent message from "):
|
|
4413
|
+
lines = lines[1:]
|
|
4414
|
+
return [line for line in lines if line.strip()]
|
|
4415
|
+
|
|
4416
|
+
|
|
4417
|
+
def _is_strong_message_fragment(compact: str) -> bool:
|
|
4418
|
+
if not compact:
|
|
4419
|
+
return False
|
|
4420
|
+
generic_prefixes = (
|
|
4421
|
+
"TeamAgentmessagefrom",
|
|
4422
|
+
"TeamAgentpeermessagefrom",
|
|
4423
|
+
"TeamAgentstoredthisresult",
|
|
4424
|
+
"TeamAgenthascollectedthisresult",
|
|
4425
|
+
"Nomanualpolling",
|
|
4426
|
+
)
|
|
4427
|
+
if compact.startswith(generic_prefixes):
|
|
4428
|
+
return False
|
|
4429
|
+
if re.fullmatch(r"[-::>›❯]+", compact):
|
|
4430
|
+
return False
|
|
4431
|
+
if re.search(r"(msg|res)_[0-9A-Fa-f]{8,}", compact):
|
|
4432
|
+
return True
|
|
4433
|
+
cjk_count = len(re.findall(r"[\u4e00-\u9fff]", compact))
|
|
4434
|
+
if cjk_count >= 4 and len(compact) >= 6:
|
|
4435
|
+
return True
|
|
4436
|
+
return len(compact) >= 18
|
|
4437
|
+
|
|
4438
|
+
|
|
4333
4439
|
def _compact_visible_text(text: str) -> str:
|
|
4334
4440
|
return re.sub(r"\s+", "", text)
|
|
4335
4441
|
|
|
@@ -4668,7 +4774,12 @@ def _deliver_pending_message(
|
|
|
4668
4774
|
if ready:
|
|
4669
4775
|
status = (
|
|
4670
4776
|
"submitted"
|
|
4671
|
-
if verification
|
|
4777
|
+
if verification
|
|
4778
|
+
in {
|
|
4779
|
+
"capture_contains_pasted_content_prompt",
|
|
4780
|
+
"capture_contains_new_pasted_content_prompt",
|
|
4781
|
+
"capture_contains_message_fragment",
|
|
4782
|
+
}
|
|
4672
4783
|
else "visible"
|
|
4673
4784
|
)
|
|
4674
4785
|
store.mark(message_id, status)
|
|
@@ -5200,6 +5311,22 @@ def _find_task(tasks: list[dict[str, Any]], task_id: str) -> dict[str, Any]:
|
|
|
5200
5311
|
raise RuntimeError(f"unknown task id: {task_id}")
|
|
5201
5312
|
|
|
5202
5313
|
|
|
5314
|
+
def _find_task_or_none(tasks: list[dict[str, Any]], task_id: str) -> dict[str, Any] | None:
|
|
5315
|
+
for task in tasks:
|
|
5316
|
+
if task.get("id") == task_id:
|
|
5317
|
+
return task
|
|
5318
|
+
return None
|
|
5319
|
+
|
|
5320
|
+
|
|
5321
|
+
def _is_message_scoped_result(store: MessageStore, envelope: dict[str, Any]) -> bool:
|
|
5322
|
+
task_id = str(envelope.get("task_id") or "")
|
|
5323
|
+
agent_id = str(envelope.get("agent_id") or "")
|
|
5324
|
+
if not task_id.startswith("msg_"):
|
|
5325
|
+
return False
|
|
5326
|
+
message = _message_by_id(store, task_id)
|
|
5327
|
+
return bool(message and message.get("recipient") == agent_id)
|
|
5328
|
+
|
|
5329
|
+
|
|
5203
5330
|
def _find_agent(spec: dict[str, Any], agent_id: str | None) -> dict[str, Any] | None:
|
|
5204
5331
|
if not agent_id:
|
|
5205
5332
|
return None
|
package/src/team_agent/state.py
CHANGED
|
@@ -10,12 +10,15 @@ from team_agent.paths import runtime_dir
|
|
|
10
10
|
from team_agent.simple_yaml import dumps
|
|
11
11
|
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
SESSION_CAPTURE_FIELDS = [
|
|
14
14
|
"session_id",
|
|
15
15
|
"rollout_path",
|
|
16
16
|
"captured_at",
|
|
17
17
|
"captured_via",
|
|
18
18
|
"attribution_confidence",
|
|
19
|
+
]
|
|
20
|
+
SESSION_STATE_FIELDS = [
|
|
21
|
+
*SESSION_CAPTURE_FIELDS,
|
|
19
22
|
"spawn_cwd",
|
|
20
23
|
]
|
|
21
24
|
|
|
@@ -24,15 +27,22 @@ def runtime_state_path(workspace: Path) -> Path:
|
|
|
24
27
|
return runtime_dir(workspace) / "state.json"
|
|
25
28
|
|
|
26
29
|
|
|
30
|
+
def normalize_agent_session_state(state: dict[str, Any]) -> None:
|
|
31
|
+
agents = state.get("agents", {})
|
|
32
|
+
if not isinstance(agents, dict):
|
|
33
|
+
return
|
|
34
|
+
for agent_state in agents.values():
|
|
35
|
+
if isinstance(agent_state, dict):
|
|
36
|
+
for field in SESSION_STATE_FIELDS:
|
|
37
|
+
agent_state.setdefault(field, None)
|
|
38
|
+
|
|
39
|
+
|
|
27
40
|
def load_runtime_state(workspace: Path) -> dict[str, Any]:
|
|
28
41
|
path = runtime_state_path(workspace)
|
|
29
42
|
if not path.exists():
|
|
30
43
|
return {"agents": {}, "tasks": [], "session_name": None}
|
|
31
44
|
state = json.loads(path.read_text(encoding="utf-8"))
|
|
32
|
-
|
|
33
|
-
if isinstance(agent_state, dict):
|
|
34
|
-
for field in SESSION_STATE_FIELDS:
|
|
35
|
-
agent_state.setdefault(field, None)
|
|
45
|
+
normalize_agent_session_state(state)
|
|
36
46
|
return state
|
|
37
47
|
|
|
38
48
|
|