synapse-cli-agent 0.1.13__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.
Files changed (131) hide show
  1. synapse/__init__.py +13 -0
  2. synapse/__main__.py +6 -0
  3. synapse/app/__init__.py +1 -0
  4. synapse/app/agent.py +492 -0
  5. synapse/app/agent_md.py +107 -0
  6. synapse/cli.py +750 -0
  7. synapse/commands/__init__.py +1 -0
  8. synapse/commands/compression.py +573 -0
  9. synapse/commands/helpers.py +22 -0
  10. synapse/commands/mcp.py +406 -0
  11. synapse/commands/model.py +173 -0
  12. synapse/commands/result.py +34 -0
  13. synapse/commands/sessions.py +443 -0
  14. synapse/commands/slash_cmds.py +521 -0
  15. synapse/commands/slash_complete.py +816 -0
  16. synapse/commands/theme.py +99 -0
  17. synapse/config.py +27 -0
  18. synapse/content/__init__.py +1 -0
  19. synapse/content/input_history.py +122 -0
  20. synapse/content/multimodal.py +733 -0
  21. synapse/content/prompts.py +249 -0
  22. synapse/content/skills_catalog.py +128 -0
  23. synapse/integrations/__init__.py +1 -0
  24. synapse/integrations/checkpoint_seed.py +281 -0
  25. synapse/integrations/codex_history.py +375 -0
  26. synapse/integrations/codex_import.py +393 -0
  27. synapse/integrations/codex_sessions.py +629 -0
  28. synapse/integrations/describe_image.py +370 -0
  29. synapse/integrations/http_clients.py +199 -0
  30. synapse/integrations/llm_openai_compat.py +90 -0
  31. synapse/integrations/llm_openai_websocket.py +187 -0
  32. synapse/integrations/mcp_client.py +646 -0
  33. synapse/integrations/vision_middleware.py +62 -0
  34. synapse/models/__init__.py +5 -0
  35. synapse/models/config.py +240 -0
  36. synapse/models/helpers.py +206 -0
  37. synapse/models/profile.py +59 -0
  38. synapse/models/registry.py +722 -0
  39. synapse/models_registry.py +7 -0
  40. synapse/observability/__init__.py +1 -0
  41. synapse/observability/startup_trace.py +127 -0
  42. synapse/runtime/__init__.py +1 -0
  43. synapse/runtime/async_runtime.py +176 -0
  44. synapse/runtime/backends.py +458 -0
  45. synapse/runtime/context_compact.py +249 -0
  46. synapse/runtime/execute_capture.py +48 -0
  47. synapse/runtime/fs_permissions.py +79 -0
  48. synapse/runtime/harness.py +57 -0
  49. synapse/runtime/hitl.py +197 -0
  50. synapse/runtime/interaction_ledger.py +82 -0
  51. synapse/runtime/middleware.py +802 -0
  52. synapse/runtime/model_request_compression_middleware.py +745 -0
  53. synapse/runtime/pathing.py +146 -0
  54. synapse/runtime/safety.py +184 -0
  55. synapse/runtime/steer.py +240 -0
  56. synapse/runtime/subagents.py +207 -0
  57. synapse/runtime/tool_ignore.py +221 -0
  58. synapse/runtime/tool_output_eval.py +118 -0
  59. synapse/runtime/tool_output_middleware.py +585 -0
  60. synapse/runtime/tool_output_usage_middleware.py +60 -0
  61. synapse/sessions/__init__.py +31 -0
  62. synapse/sessions/cancel_repair.py +208 -0
  63. synapse/sessions/session_recap.py +174 -0
  64. synapse/sessions/store.py +695 -0
  65. synapse/sessions/transcript.py +754 -0
  66. synapse/settings/__init__.py +5 -0
  67. synapse/settings/config_paths.py +184 -0
  68. synapse/settings/schema.py +464 -0
  69. synapse/tool_output/__init__.py +59 -0
  70. synapse/tool_output/detection.py +170 -0
  71. synapse/tool_output/metrics.py +32 -0
  72. synapse/tool_output/models.py +173 -0
  73. synapse/tool_output/pipeline.py +330 -0
  74. synapse/tool_output/repository.py +721 -0
  75. synapse/tool_output/transformers.py +648 -0
  76. synapse/tools/__init__.py +5 -0
  77. synapse/tools/session_tools.py +204 -0
  78. synapse/ui/__init__.py +10 -0
  79. synapse/ui/bottombar/__init__.py +73 -0
  80. synapse/ui/bottombar/components/__init__.py +143 -0
  81. synapse/ui/bottombar/components/key_hints.py +30 -0
  82. synapse/ui/bottombar/components/mcp.py +64 -0
  83. synapse/ui/bottombar/components/mode.py +24 -0
  84. synapse/ui/bottombar/components/model.py +28 -0
  85. synapse/ui/bottombar/components/thread.py +29 -0
  86. synapse/ui/bottombar/context.py +36 -0
  87. synapse/ui/bottombar/core.py +74 -0
  88. synapse/ui/dialogs/__init__.py +25 -0
  89. synapse/ui/dialogs/base.py +362 -0
  90. synapse/ui/dialogs/codex_session_list.py +84 -0
  91. synapse/ui/dialogs/compression_diagnostics.py +210 -0
  92. synapse/ui/dialogs/git_explore.py +702 -0
  93. synapse/ui/dialogs/mcp_panel.py +407 -0
  94. synapse/ui/dialogs/model_picker.py +128 -0
  95. synapse/ui/dialogs/safety_panel.py +63 -0
  96. synapse/ui/dialogs/session_list.py +98 -0
  97. synapse/ui/dialogs/theme_designer.py +863 -0
  98. synapse/ui/dialogs/theme_picker.py +113 -0
  99. synapse/ui/git_explore/__init__.py +31 -0
  100. synapse/ui/git_explore/engine.py +82 -0
  101. synapse/ui/git_explore/provider.py +242 -0
  102. synapse/ui/git_explore/unified.py +85 -0
  103. synapse/ui/rendering.py +350 -0
  104. synapse/ui/sink.py +70 -0
  105. synapse/ui/steer_widget.py +367 -0
  106. synapse/ui/stream.py +1207 -0
  107. synapse/ui/stream_events.py +421 -0
  108. synapse/ui/stream_runtime.py +252 -0
  109. synapse/ui/theme.py +1154 -0
  110. synapse/ui/timeline.py +621 -0
  111. synapse/ui/topbar/__init__.py +97 -0
  112. synapse/ui/topbar/components/__init__.py +150 -0
  113. synapse/ui/topbar/components/branch.py +41 -0
  114. synapse/ui/topbar/components/title.py +24 -0
  115. synapse/ui/topbar/components/tool_output.py +24 -0
  116. synapse/ui/topbar/components/usage.py +24 -0
  117. synapse/ui/topbar/components/workspace.py +32 -0
  118. synapse/ui/topbar/context.py +32 -0
  119. synapse/ui/topbar/core.py +979 -0
  120. synapse/ui/topbar/git_changes_popover.py +178 -0
  121. synapse/ui/topbar/git_chrome.py +475 -0
  122. synapse/ui/topbar/tool_output_popover.py +84 -0
  123. synapse/ui/topbar/widget.py +474 -0
  124. synapse/ui/tui.py +5717 -0
  125. synapse/ui/turn_rail.py +71 -0
  126. synapse/ui/user_turn.py +83 -0
  127. synapse/ui/welcome.py +261 -0
  128. synapse_cli_agent-0.1.13.dist-info/METADATA +412 -0
  129. synapse_cli_agent-0.1.13.dist-info/RECORD +131 -0
  130. synapse_cli_agent-0.1.13.dist-info/WHEEL +4 -0
  131. synapse_cli_agent-0.1.13.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,48 @@
1
+ """Per-tool-call capture channel for untruncated shell output.
2
+
3
+ ``FilesystemMiddleware`` only converts ``ExecuteResponse.output`` into a
4
+ ``ToolMessage``. The response contract cannot carry artifacts, so the backend
5
+ records the full pre-truncation output in this context-local side channel while
6
+ the tool-output transformation middleware invokes the execute tool.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from contextvars import ContextVar, Token
12
+ from dataclasses import dataclass
13
+
14
+
15
+ @dataclass
16
+ class ExecuteOutputCapture:
17
+ """Full and model-visible backend output for one execute invocation."""
18
+
19
+ full_output: str | None = None
20
+ displayed_output: str | None = None
21
+ truncated: bool = False
22
+
23
+
24
+ _active_capture: ContextVar[ExecuteOutputCapture | None] = ContextVar(
25
+ "synapse_execute_output_capture",
26
+ default=None,
27
+ )
28
+
29
+
30
+ def begin_execute_capture() -> tuple[ExecuteOutputCapture, Token[ExecuteOutputCapture | None]]:
31
+ """Install an empty capture object for the current tool-call context."""
32
+ capture = ExecuteOutputCapture()
33
+ return capture, _active_capture.set(capture)
34
+
35
+
36
+ def end_execute_capture(token: Token[ExecuteOutputCapture | None]) -> None:
37
+ """Restore the enclosing tool-call capture context."""
38
+ _active_capture.reset(token)
39
+
40
+
41
+ def capture_execute_output(*, full_output: str, displayed_output: str, truncated: bool) -> None:
42
+ """Record the backend response before its size limit discarded content."""
43
+ capture = _active_capture.get()
44
+ if capture is None:
45
+ return
46
+ capture.full_output = full_output
47
+ capture.displayed_output = displayed_output
48
+ capture.truncated = truncated
@@ -0,0 +1,79 @@
1
+ """FilesystemPermission helpers for create_deep_agent(permissions=...).
2
+
3
+ Important: deepagents currently rejects permissions when the backend supports
4
+ command execution (LocalShellBackend / SandboxBackendProtocol). This project
5
+ always uses LocalShellBackend, so ``build_filesystem_permissions`` returns
6
+ None unless ``force=True`` (for non-shell backends / experiments).
7
+
8
+ Product readonly mode should use harness tool exclusion instead
9
+ (``apply_harness_exclusions`` / ``AGENT_READONLY``).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+
17
+ def build_filesystem_permissions(
18
+ *,
19
+ enabled: bool = False,
20
+ readonly: bool = False,
21
+ deny_paths: list[str] | None = None,
22
+ force: bool = False,
23
+ shell_backend: bool = True,
24
+ ) -> list[Any] | None:
25
+ """Build deepagents FilesystemPermission rules.
26
+
27
+ Paths must be POSIX-style and start with '/'. With LocalShellBackend +
28
+ virtual_mode, workspace roots are typically exposed under '/'.
29
+
30
+ Returns None when ``shell_backend`` is True (default), because permissions
31
+ + execute backends are unsupported by FilesystemMiddleware.
32
+ """
33
+ if shell_backend and not force:
34
+ # Avoid hard failure at agent build time on LocalShellBackend.
35
+ return None
36
+
37
+ if not enabled and not readonly and not deny_paths:
38
+ return None
39
+
40
+ from deepagents import FilesystemPermission
41
+
42
+ rules: list[Any] = []
43
+
44
+ if readonly:
45
+ # Deny writes everywhere; reads remain allowed by default.
46
+ rules.append(
47
+ FilesystemPermission(
48
+ operations=["write"],
49
+ paths=["/**"],
50
+ mode="deny",
51
+ )
52
+ )
53
+
54
+ for path in deny_paths or []:
55
+ p = path.strip().replace("\\", "/")
56
+ if not p:
57
+ continue
58
+ if not p.startswith("/"):
59
+ p = "/" + p
60
+ rules.append(
61
+ FilesystemPermission(
62
+ operations=["read", "write"],
63
+ paths=[p],
64
+ mode="deny",
65
+ )
66
+ )
67
+
68
+ # Sensible defaults when permissions feature is on.
69
+ if enabled and not deny_paths:
70
+ for p in ("/**/.env", "/**/.env.*", "/**/secrets/**"):
71
+ rules.append(
72
+ FilesystemPermission(
73
+ operations=["read", "write"],
74
+ paths=[p],
75
+ mode="deny",
76
+ )
77
+ )
78
+
79
+ return rules or None
@@ -0,0 +1,57 @@
1
+ """HarnessProfile registration helpers (excluded_tools etc.)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+
7
+ _DEFAULT_EXCLUDES = frozenset({"ls", "grep"})
8
+ _DEFAULT_READONLY_EXCLUDES = frozenset(
9
+ {
10
+ "execute",
11
+ "write_file",
12
+ "edit_file",
13
+ }
14
+ )
15
+
16
+
17
+ def apply_harness_exclusions(
18
+ model_spec: str,
19
+ *,
20
+ readonly: bool = False,
21
+ excluded_tools: Iterable[str] | None = None,
22
+ ) -> frozenset[str]:
23
+ """Register harness excluded_tools for the active model/provider.
24
+
25
+ ``ls`` and ``grep`` are excluded by default because ``execute`` can run the
26
+ project's native directory and search commands. Read-only mode additionally
27
+ excludes ``execute``, ``write_file``, and ``edit_file``.
28
+
29
+ deepagents only removes built-in tools via HarnessProfile.excluded_tools
30
+ (tools= is additive). Registration is additive/merge under the same key.
31
+ """
32
+ names = set(_DEFAULT_EXCLUDES)
33
+ names.update(excluded_tools or [])
34
+ if readonly:
35
+ names |= set(_DEFAULT_READONLY_EXCLUDES)
36
+ excluded = frozenset(n.strip() for n in names if n and n.strip())
37
+ if not excluded:
38
+ return frozenset()
39
+
40
+ from deepagents import (
41
+ GeneralPurposeSubagentProfile,
42
+ HarnessProfile,
43
+ register_harness_profile,
44
+ )
45
+
46
+ profile = HarnessProfile(
47
+ excluded_tools=excluded,
48
+ general_purpose_subagent=GeneralPurposeSubagentProfile(enabled=False),
49
+ )
50
+ # Model-level and provider-level keys so both string specs and prebuilt
51
+ # BaseChatModel resolution paths can match.
52
+ register_harness_profile(model_spec, profile)
53
+ if ":" in model_spec:
54
+ provider = model_spec.split(":", 1)[0]
55
+ if provider:
56
+ register_harness_profile(provider, profile)
57
+ return excluded
@@ -0,0 +1,197 @@
1
+ """Human-in-the-loop interrupt detection and resume helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+
9
+ @dataclass
10
+ class PendingAction:
11
+ """One tool call waiting for human decision."""
12
+
13
+ name: str
14
+ args: dict[str, Any] = field(default_factory=dict)
15
+ description: str = ""
16
+ allowed_decisions: list[str] = field(default_factory=lambda: ["approve", "reject"])
17
+
18
+
19
+ @dataclass
20
+ class PendingInterrupt:
21
+ """Graph is paused for HITL."""
22
+
23
+ actions: list[PendingAction] = field(default_factory=list)
24
+ raw: Any = None
25
+
26
+
27
+ def _as_dict(obj: Any) -> dict[str, Any]:
28
+ if obj is None:
29
+ return {}
30
+ if isinstance(obj, dict):
31
+ return obj
32
+ if hasattr(obj, "model_dump"):
33
+ try:
34
+ data = obj.model_dump()
35
+ if isinstance(data, dict):
36
+ return data
37
+ except Exception: # noqa: BLE001
38
+ pass
39
+ out: dict[str, Any] = {}
40
+ for key in (
41
+ "name",
42
+ "args",
43
+ "description",
44
+ "action_request",
45
+ "review_config",
46
+ "value",
47
+ "action",
48
+ ):
49
+ if hasattr(obj, key):
50
+ out[key] = getattr(obj, key)
51
+ return out
52
+
53
+
54
+ def _parse_action_request(item: Any) -> PendingAction | None:
55
+ data = _as_dict(item)
56
+ # Nested shapes: {action_request: {name, args, description}, review_config: {...}}
57
+ ar = data.get("action_request") or data.get("action") or data
58
+ ar = _as_dict(ar) if not isinstance(ar, dict) else ar
59
+ name = str(ar.get("name") or data.get("name") or "").strip()
60
+ if not name:
61
+ return None
62
+ args = ar.get("args") if isinstance(ar.get("args"), dict) else {}
63
+ desc = str(ar.get("description") or data.get("description") or "")
64
+ rc = data.get("review_config") or {}
65
+ rc = _as_dict(rc) if not isinstance(rc, dict) else rc
66
+ allowed = list(rc.get("allowed_decisions") or ["approve", "reject"])
67
+ return PendingAction(name=name, args=args or {}, description=desc, allowed_decisions=allowed)
68
+
69
+
70
+ def extract_pending_interrupt(agent: Any, config: dict[str, Any]) -> PendingInterrupt | None:
71
+ """Return pending HITL interrupt for the thread, if any."""
72
+ if agent is None:
73
+ return None
74
+ get_state = getattr(agent, "get_state", None)
75
+ if not callable(get_state):
76
+ return None
77
+ try:
78
+ state = get_state(config)
79
+ except Exception: # noqa: BLE001
80
+ return None
81
+
82
+ actions: list[PendingAction] = []
83
+ raw_items: list[Any] = []
84
+
85
+ # Preferred: state.interrupts (LangGraph >= 0.2)
86
+ interrupts = getattr(state, "interrupts", None) or ()
87
+ for item in interrupts:
88
+ raw_items.append(item)
89
+ value = getattr(item, "value", item)
90
+ # HITLRequest: {action_requests: [...], review_configs: [...]}
91
+ vdict = _as_dict(value)
92
+ reqs = vdict.get("action_requests") or vdict.get("actionRequests")
93
+ if isinstance(reqs, list) and reqs:
94
+ reviews = vdict.get("review_configs") or vdict.get("reviewConfigs") or []
95
+ for i, req in enumerate(reqs):
96
+ packed = {"action_request": req}
97
+ if i < len(reviews):
98
+ packed["review_config"] = reviews[i]
99
+ act = _parse_action_request(packed)
100
+ if act:
101
+ actions.append(act)
102
+ continue
103
+ act = _parse_action_request(value)
104
+ if act:
105
+ actions.append(act)
106
+
107
+ # Fallback: tasks with interrupts
108
+ if not actions:
109
+ tasks = getattr(state, "tasks", None) or ()
110
+ for task in tasks:
111
+ tintr = getattr(task, "interrupts", None) or ()
112
+ for item in tintr:
113
+ raw_items.append(item)
114
+ value = getattr(item, "value", item)
115
+ vdict = _as_dict(value)
116
+ reqs = vdict.get("action_requests") or []
117
+ if isinstance(reqs, list) and reqs:
118
+ for req in reqs:
119
+ act = _parse_action_request({"action_request": req})
120
+ if act:
121
+ actions.append(act)
122
+ else:
123
+ act = _parse_action_request(value)
124
+ if act:
125
+ actions.append(act)
126
+
127
+ if not actions and not raw_items:
128
+ # Graph may still be mid-node with next != ()
129
+ nxt = getattr(state, "next", None) or ()
130
+ if not nxt:
131
+ return None
132
+ # No parseable actions
133
+ return None
134
+
135
+ if not actions:
136
+ return PendingInterrupt(actions=[], raw=raw_items or interrupts)
137
+
138
+ return PendingInterrupt(actions=actions, raw=raw_items or interrupts)
139
+
140
+
141
+ def format_interrupt_lines(pending: PendingInterrupt) -> list[str]:
142
+ """Human-readable pending approvals."""
143
+ if not pending.actions:
144
+ return [
145
+ "approval required (unparsed interrupt)",
146
+ "use /approve or /reject after inspecting agent state",
147
+ ]
148
+ lines = [f"approval required for {len(pending.actions)} tool call(s):"]
149
+ for i, act in enumerate(pending.actions, 1):
150
+ args_preview = str(act.args)
151
+ if len(args_preview) > 120:
152
+ args_preview = args_preview[:119] + "…"
153
+ lines.append(f" {i}. {act.name} args={args_preview}")
154
+ if act.description:
155
+ one = " ".join(act.description.split())
156
+ if len(one) > 140:
157
+ one = one[:139] + "…"
158
+ lines.append(f" {one}")
159
+ lines.append("decide: /approve | /reject [reason]")
160
+ return lines
161
+
162
+
163
+ def build_decisions(
164
+ pending: PendingInterrupt,
165
+ *,
166
+ action: str,
167
+ message: str | None = None,
168
+ ) -> list[dict[str, Any]]:
169
+ """Build resume decisions list matching pending action count."""
170
+ kind = (action or "approve").strip().casefold()
171
+ if kind not in {"approve", "reject"}:
172
+ kind = "approve"
173
+ decisions: list[dict[str, Any]] = []
174
+ n = max(1, len(pending.actions))
175
+ for _ in range(n):
176
+ if kind == "approve":
177
+ decisions.append({"type": "approve"})
178
+ else:
179
+ d: dict[str, Any] = {"type": "reject"}
180
+ if message:
181
+ d["message"] = message
182
+ else:
183
+ d["message"] = "Rejected by user via /reject"
184
+ decisions.append(d)
185
+ return decisions
186
+
187
+
188
+ def build_resume_payload(decisions: list[dict[str, Any]]) -> Any:
189
+ """LangGraph Command(resume=...) payload for HITL middleware."""
190
+ from langgraph.types import Command
191
+
192
+ return Command(resume={"decisions": decisions})
193
+
194
+
195
+ def has_pending_interrupt(agent: Any, config: dict[str, Any]) -> bool:
196
+ pending = extract_pending_interrupt(agent, config)
197
+ return pending is not None and bool(pending.actions or pending.raw)
@@ -0,0 +1,82 @@
1
+ """Thread-scoped turn and model-call correlation for compression diagnostics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import threading
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class InteractionPosition:
13
+ turn_id: str
14
+ turn_index: int
15
+ model_call_index: int
16
+
17
+
18
+ @dataclass
19
+ class _ThreadPosition:
20
+ user_fingerprint: str = ""
21
+ turn_id: str = ""
22
+ turn_index: int = 0
23
+ model_call_index: int = 0
24
+
25
+
26
+ _lock = threading.RLock()
27
+ _positions: dict[str, _ThreadPosition] = {}
28
+
29
+
30
+ def user_fingerprint(content: Any) -> str:
31
+ return hashlib.sha256(str(content or "").encode("utf-8")).hexdigest()[:16]
32
+
33
+
34
+ def begin_model_call(
35
+ thread_id: str,
36
+ current_user_content: Any,
37
+ *,
38
+ turn_index_hint: int = 0,
39
+ model_call_index_hint: int = 0,
40
+ ) -> InteractionPosition:
41
+ """Advance a thread position using deterministic persisted-position hints."""
42
+ fingerprint = user_fingerprint(current_user_content)
43
+ with _lock:
44
+ state = _positions.setdefault(thread_id, _ThreadPosition())
45
+ hinted_turn = max(0, int(turn_index_hint or 0))
46
+ new_turn = (
47
+ not state.turn_id
48
+ or state.user_fingerprint != fingerprint
49
+ or (hinted_turn and state.turn_index != hinted_turn)
50
+ )
51
+ if new_turn:
52
+ state.turn_index = hinted_turn or state.turn_index + 1
53
+ state.model_call_index = max(0, int(model_call_index_hint or 0))
54
+ state.user_fingerprint = fingerprint
55
+ seed = f"{thread_id}:{state.turn_index}:{fingerprint}"
56
+ state.turn_id = hashlib.sha256(seed.encode("utf-8")).hexdigest()[:16]
57
+ else:
58
+ state.model_call_index = max(
59
+ state.model_call_index, max(0, int(model_call_index_hint or 0))
60
+ )
61
+ state.model_call_index += 1
62
+ return InteractionPosition(
63
+ turn_id=state.turn_id,
64
+ turn_index=state.turn_index,
65
+ model_call_index=state.model_call_index,
66
+ )
67
+
68
+
69
+ def current_position(thread_id: str) -> InteractionPosition:
70
+ with _lock:
71
+ state = _positions.get(thread_id) or _ThreadPosition()
72
+ return InteractionPosition(
73
+ turn_id=state.turn_id,
74
+ turn_index=state.turn_index,
75
+ model_call_index=state.model_call_index,
76
+ )
77
+
78
+
79
+ def clear_interaction_positions() -> None:
80
+ """Reset process-local positions for tests and clean agent restarts."""
81
+ with _lock:
82
+ _positions.clear()