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.
- synapse/__init__.py +13 -0
- synapse/__main__.py +6 -0
- synapse/app/__init__.py +1 -0
- synapse/app/agent.py +492 -0
- synapse/app/agent_md.py +107 -0
- synapse/cli.py +750 -0
- synapse/commands/__init__.py +1 -0
- synapse/commands/compression.py +573 -0
- synapse/commands/helpers.py +22 -0
- synapse/commands/mcp.py +406 -0
- synapse/commands/model.py +173 -0
- synapse/commands/result.py +34 -0
- synapse/commands/sessions.py +443 -0
- synapse/commands/slash_cmds.py +521 -0
- synapse/commands/slash_complete.py +816 -0
- synapse/commands/theme.py +99 -0
- synapse/config.py +27 -0
- synapse/content/__init__.py +1 -0
- synapse/content/input_history.py +122 -0
- synapse/content/multimodal.py +733 -0
- synapse/content/prompts.py +249 -0
- synapse/content/skills_catalog.py +128 -0
- synapse/integrations/__init__.py +1 -0
- synapse/integrations/checkpoint_seed.py +281 -0
- synapse/integrations/codex_history.py +375 -0
- synapse/integrations/codex_import.py +393 -0
- synapse/integrations/codex_sessions.py +629 -0
- synapse/integrations/describe_image.py +370 -0
- synapse/integrations/http_clients.py +199 -0
- synapse/integrations/llm_openai_compat.py +90 -0
- synapse/integrations/llm_openai_websocket.py +187 -0
- synapse/integrations/mcp_client.py +646 -0
- synapse/integrations/vision_middleware.py +62 -0
- synapse/models/__init__.py +5 -0
- synapse/models/config.py +240 -0
- synapse/models/helpers.py +206 -0
- synapse/models/profile.py +59 -0
- synapse/models/registry.py +722 -0
- synapse/models_registry.py +7 -0
- synapse/observability/__init__.py +1 -0
- synapse/observability/startup_trace.py +127 -0
- synapse/runtime/__init__.py +1 -0
- synapse/runtime/async_runtime.py +176 -0
- synapse/runtime/backends.py +458 -0
- synapse/runtime/context_compact.py +249 -0
- synapse/runtime/execute_capture.py +48 -0
- synapse/runtime/fs_permissions.py +79 -0
- synapse/runtime/harness.py +57 -0
- synapse/runtime/hitl.py +197 -0
- synapse/runtime/interaction_ledger.py +82 -0
- synapse/runtime/middleware.py +802 -0
- synapse/runtime/model_request_compression_middleware.py +745 -0
- synapse/runtime/pathing.py +146 -0
- synapse/runtime/safety.py +184 -0
- synapse/runtime/steer.py +240 -0
- synapse/runtime/subagents.py +207 -0
- synapse/runtime/tool_ignore.py +221 -0
- synapse/runtime/tool_output_eval.py +118 -0
- synapse/runtime/tool_output_middleware.py +585 -0
- synapse/runtime/tool_output_usage_middleware.py +60 -0
- synapse/sessions/__init__.py +31 -0
- synapse/sessions/cancel_repair.py +208 -0
- synapse/sessions/session_recap.py +174 -0
- synapse/sessions/store.py +695 -0
- synapse/sessions/transcript.py +754 -0
- synapse/settings/__init__.py +5 -0
- synapse/settings/config_paths.py +184 -0
- synapse/settings/schema.py +464 -0
- synapse/tool_output/__init__.py +59 -0
- synapse/tool_output/detection.py +170 -0
- synapse/tool_output/metrics.py +32 -0
- synapse/tool_output/models.py +173 -0
- synapse/tool_output/pipeline.py +330 -0
- synapse/tool_output/repository.py +721 -0
- synapse/tool_output/transformers.py +648 -0
- synapse/tools/__init__.py +5 -0
- synapse/tools/session_tools.py +204 -0
- synapse/ui/__init__.py +10 -0
- synapse/ui/bottombar/__init__.py +73 -0
- synapse/ui/bottombar/components/__init__.py +143 -0
- synapse/ui/bottombar/components/key_hints.py +30 -0
- synapse/ui/bottombar/components/mcp.py +64 -0
- synapse/ui/bottombar/components/mode.py +24 -0
- synapse/ui/bottombar/components/model.py +28 -0
- synapse/ui/bottombar/components/thread.py +29 -0
- synapse/ui/bottombar/context.py +36 -0
- synapse/ui/bottombar/core.py +74 -0
- synapse/ui/dialogs/__init__.py +25 -0
- synapse/ui/dialogs/base.py +362 -0
- synapse/ui/dialogs/codex_session_list.py +84 -0
- synapse/ui/dialogs/compression_diagnostics.py +210 -0
- synapse/ui/dialogs/git_explore.py +702 -0
- synapse/ui/dialogs/mcp_panel.py +407 -0
- synapse/ui/dialogs/model_picker.py +128 -0
- synapse/ui/dialogs/safety_panel.py +63 -0
- synapse/ui/dialogs/session_list.py +98 -0
- synapse/ui/dialogs/theme_designer.py +863 -0
- synapse/ui/dialogs/theme_picker.py +113 -0
- synapse/ui/git_explore/__init__.py +31 -0
- synapse/ui/git_explore/engine.py +82 -0
- synapse/ui/git_explore/provider.py +242 -0
- synapse/ui/git_explore/unified.py +85 -0
- synapse/ui/rendering.py +350 -0
- synapse/ui/sink.py +70 -0
- synapse/ui/steer_widget.py +367 -0
- synapse/ui/stream.py +1207 -0
- synapse/ui/stream_events.py +421 -0
- synapse/ui/stream_runtime.py +252 -0
- synapse/ui/theme.py +1154 -0
- synapse/ui/timeline.py +621 -0
- synapse/ui/topbar/__init__.py +97 -0
- synapse/ui/topbar/components/__init__.py +150 -0
- synapse/ui/topbar/components/branch.py +41 -0
- synapse/ui/topbar/components/title.py +24 -0
- synapse/ui/topbar/components/tool_output.py +24 -0
- synapse/ui/topbar/components/usage.py +24 -0
- synapse/ui/topbar/components/workspace.py +32 -0
- synapse/ui/topbar/context.py +32 -0
- synapse/ui/topbar/core.py +979 -0
- synapse/ui/topbar/git_changes_popover.py +178 -0
- synapse/ui/topbar/git_chrome.py +475 -0
- synapse/ui/topbar/tool_output_popover.py +84 -0
- synapse/ui/topbar/widget.py +474 -0
- synapse/ui/tui.py +5717 -0
- synapse/ui/turn_rail.py +71 -0
- synapse/ui/user_turn.py +83 -0
- synapse/ui/welcome.py +261 -0
- synapse_cli_agent-0.1.13.dist-info/METADATA +412 -0
- synapse_cli_agent-0.1.13.dist-info/RECORD +131 -0
- synapse_cli_agent-0.1.13.dist-info/WHEEL +4 -0
- 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
|
synapse/runtime/hitl.py
ADDED
|
@@ -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()
|