deep-agent-cli 0.1.0__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.
- agent/__init__.py +42 -0
- agent/attachments.py +303 -0
- agent/bootstrap.py +44 -0
- agent/cancel.py +107 -0
- agent/cli/__init__.py +5 -0
- agent/cli/app.py +1768 -0
- agent/cli/clipboard.py +224 -0
- agent/cli/commands.py +94 -0
- agent/cli/gitinfo.py +84 -0
- agent/cli/input.py +65 -0
- agent/cli/interactions.py +187 -0
- agent/cli/main.py +124 -0
- agent/cli/previews.py +710 -0
- agent/cli/rendering.py +770 -0
- agent/cli/session_controller.py +221 -0
- agent/cli/state.py +326 -0
- agent/config.example.yaml +76 -0
- agent/config.py +528 -0
- agent/control.py +171 -0
- agent/factory.py +232 -0
- agent/file_mutation.py +5 -0
- agent/llm.py +339 -0
- agent/middleware/__init__.py +9 -0
- agent/middleware/attachments.py +31 -0
- agent/middleware/cancel_tools.py +39 -0
- agent/middleware/pause.py +18 -0
- agent/middleware/recovery.py +65 -0
- agent/middleware/steering.py +35 -0
- agent/middleware/tool_arg_hints.py +128 -0
- agent/middleware/workspace_filesystem.py +38 -0
- agent/middleware/write_operation.py +60 -0
- agent/network.py +30 -0
- agent/permission.py +80 -0
- agent/runner.py +1393 -0
- agent/sandbox.py +699 -0
- agent/session.py +431 -0
- agent/session_lock.py +223 -0
- agent/session_runtime.py +209 -0
- agent/stream.py +168 -0
- agent/tools/__init__.py +9 -0
- agent/tools/examples.py +30 -0
- agent/tools/execute.py +73 -0
- agent/tools/human_input.py +170 -0
- agent/tools/human_interaction.py +101 -0
- agent/tools/web_search.py +131 -0
- deep_agent_cli-0.1.0.dist-info/METADATA +408 -0
- deep_agent_cli-0.1.0.dist-info/RECORD +50 -0
- deep_agent_cli-0.1.0.dist-info/WHEEL +4 -0
- deep_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
- deep_agent_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""CLI session workflow: /new, /resume, and waiting out a session held elsewhere.
|
|
2
|
+
|
|
3
|
+
The controller owns the resume picker, the busy-session wait, and applying a
|
|
4
|
+
restored snapshot to the visible state. It drives the runner but never touches
|
|
5
|
+
the prompt_toolkit layout.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
from agent.cli.interactions import InteractionController
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from agent.cli.app import CliApplication
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Shown while the runner waits for a session held by another window.
|
|
19
|
+
WAIT_STATUS = "Waiting for session {} · Esc cancels"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SessionController:
|
|
23
|
+
def __init__(self, app: CliApplication) -> None:
|
|
24
|
+
self._app = app
|
|
25
|
+
self.open_picker_on_start = False
|
|
26
|
+
self.startup_session_id: str | None = None
|
|
27
|
+
self._wait_target: str | None = None
|
|
28
|
+
self._wait_cancelled = False
|
|
29
|
+
self._wait_task: asyncio.Task[None] | None = None
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def waiting(self) -> bool:
|
|
33
|
+
return self._wait_target is not None
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def wait_target(self) -> str | None:
|
|
37
|
+
return self._wait_target
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def wait_task(self) -> asyncio.Task[None] | None:
|
|
41
|
+
return self._wait_task
|
|
42
|
+
|
|
43
|
+
def wait_status(self) -> str:
|
|
44
|
+
assert self._wait_target is not None
|
|
45
|
+
return WAIT_STATUS.format(self._wait_target[:8])
|
|
46
|
+
|
|
47
|
+
def schedule_start(self, loop: asyncio.AbstractEventLoop) -> None:
|
|
48
|
+
if self.startup_session_id is not None:
|
|
49
|
+
loop.create_task(self._switch_with_wait(self.startup_session_id))
|
|
50
|
+
elif self.open_picker_on_start:
|
|
51
|
+
loop.create_task(self.resume())
|
|
52
|
+
|
|
53
|
+
async def start(self) -> None:
|
|
54
|
+
if self.startup_session_id is not None:
|
|
55
|
+
await self._switch_with_wait(self.startup_session_id)
|
|
56
|
+
elif self.open_picker_on_start:
|
|
57
|
+
await self.resume()
|
|
58
|
+
|
|
59
|
+
def new_session(self) -> None:
|
|
60
|
+
app = self._app
|
|
61
|
+
if app.state.running:
|
|
62
|
+
app.set_status("Cancel the active run before starting a new session")
|
|
63
|
+
return
|
|
64
|
+
if self.waiting:
|
|
65
|
+
app.set_status(self.wait_status())
|
|
66
|
+
return
|
|
67
|
+
app._restore_queued_to_editor(app.buffer)
|
|
68
|
+
try:
|
|
69
|
+
info = app.runner.new_session()
|
|
70
|
+
except RuntimeError as exc:
|
|
71
|
+
app.state.add_system(str(exc), error=True)
|
|
72
|
+
return
|
|
73
|
+
app.state.clear()
|
|
74
|
+
app.state.attachments.clear()
|
|
75
|
+
app.interaction = None
|
|
76
|
+
app._reviewing = False
|
|
77
|
+
app._transcript_anchor = None
|
|
78
|
+
app._renderer.clear()
|
|
79
|
+
app.state.add_system(f"Started session {info.id}")
|
|
80
|
+
app.set_status("Ready")
|
|
81
|
+
|
|
82
|
+
async def resume(self, arg: str = "") -> None:
|
|
83
|
+
app = self._app
|
|
84
|
+
if app.state.running:
|
|
85
|
+
app.set_status("Cancel the active run before switching sessions")
|
|
86
|
+
return
|
|
87
|
+
if app.runner.session_store is None:
|
|
88
|
+
app.state.add_system(
|
|
89
|
+
"/resume is unavailable: persistent session storage is not configured", error=True,
|
|
90
|
+
)
|
|
91
|
+
return
|
|
92
|
+
if self.waiting:
|
|
93
|
+
return
|
|
94
|
+
if arg.strip():
|
|
95
|
+
app._restore_queued_to_editor(app.buffer)
|
|
96
|
+
await self._switch_with_wait(arg.strip())
|
|
97
|
+
return
|
|
98
|
+
sessions = self._sessions_with_content(limit=50)
|
|
99
|
+
if not sessions:
|
|
100
|
+
app.state.add_system("No sessions with conversation content.", error=True)
|
|
101
|
+
return
|
|
102
|
+
options = [
|
|
103
|
+
{
|
|
104
|
+
"value": item.id,
|
|
105
|
+
"label": f"{item.id[:8]} · {item.last_run_status.value} · {item.title[:40]}",
|
|
106
|
+
"right_label": item.updated_at.astimezone().strftime("%Y-%m-%d %H:%M:%S"),
|
|
107
|
+
}
|
|
108
|
+
for item in sessions
|
|
109
|
+
]
|
|
110
|
+
app.interaction = InteractionController(
|
|
111
|
+
kind="resume",
|
|
112
|
+
title="Resume session",
|
|
113
|
+
question="Select a session to restore",
|
|
114
|
+
fields=[{
|
|
115
|
+
"id": "session",
|
|
116
|
+
"type": "single_select",
|
|
117
|
+
"label": "Session",
|
|
118
|
+
"required": True,
|
|
119
|
+
"options": options,
|
|
120
|
+
}],
|
|
121
|
+
)
|
|
122
|
+
app.set_status("Select a session · Enter confirm · Esc cancel")
|
|
123
|
+
app.application.invalidate()
|
|
124
|
+
|
|
125
|
+
def begin_switch(self, session_id: str) -> None:
|
|
126
|
+
"""Picker Enter: switch immediately, or enter the visible wait."""
|
|
127
|
+
app = self._app
|
|
128
|
+
try:
|
|
129
|
+
app._restore_queued_to_editor(app.buffer)
|
|
130
|
+
result = app.runner.begin_session_switch(session_id)
|
|
131
|
+
except (KeyError, RuntimeError) as exc:
|
|
132
|
+
app.state.add_system(str(exc), error=True)
|
|
133
|
+
return
|
|
134
|
+
if result.busy:
|
|
135
|
+
self._enter_wait(result.target_id)
|
|
136
|
+
return
|
|
137
|
+
assert result.snapshot is not None
|
|
138
|
+
self.apply_snapshot(result.snapshot)
|
|
139
|
+
|
|
140
|
+
def apply_snapshot(self, snapshot: Any) -> None:
|
|
141
|
+
app = self._app
|
|
142
|
+
app.state.load_transcript(snapshot.transcript)
|
|
143
|
+
app.state.todos = list(snapshot.todos)
|
|
144
|
+
app.interaction = None
|
|
145
|
+
app._reviewing = False
|
|
146
|
+
app.state.add_system(
|
|
147
|
+
f"Resumed session {snapshot.info.id} (last run: {snapshot.info.last_run_status.value})"
|
|
148
|
+
)
|
|
149
|
+
for notice in snapshot.notices:
|
|
150
|
+
app.state.add_system(notice)
|
|
151
|
+
if not app._reopen_pending_interaction(notify_missing=False):
|
|
152
|
+
app.set_status("Ready")
|
|
153
|
+
app._transcript_anchor = None
|
|
154
|
+
app._renderer.clear()
|
|
155
|
+
app.state.usage = dict(app.runner.latest_usage())
|
|
156
|
+
app.application.invalidate()
|
|
157
|
+
|
|
158
|
+
def cancel_wait(self) -> None:
|
|
159
|
+
self._wait_cancelled = True
|
|
160
|
+
self._app.set_status("Cancelling wait…")
|
|
161
|
+
|
|
162
|
+
def notify_exit(self) -> None:
|
|
163
|
+
self._wait_cancelled = True
|
|
164
|
+
|
|
165
|
+
async def _switch_with_wait(self, session_id: str) -> None:
|
|
166
|
+
app = self._app
|
|
167
|
+
try:
|
|
168
|
+
result = await app._run_blocking(app.runner.begin_session_switch, session_id)
|
|
169
|
+
except (KeyError, RuntimeError) as exc:
|
|
170
|
+
app.state.add_system(str(exc), error=True)
|
|
171
|
+
return
|
|
172
|
+
if result.busy:
|
|
173
|
+
self._enter_wait(result.target_id)
|
|
174
|
+
return
|
|
175
|
+
assert result.snapshot is not None
|
|
176
|
+
self.apply_snapshot(result.snapshot)
|
|
177
|
+
|
|
178
|
+
def _enter_wait(self, target_id: str) -> None:
|
|
179
|
+
"""Detach into a visible wait for a session busy in another window."""
|
|
180
|
+
app = self._app
|
|
181
|
+
self._wait_target = target_id
|
|
182
|
+
self._wait_cancelled = False
|
|
183
|
+
app.interaction = None
|
|
184
|
+
app.state.add_system(
|
|
185
|
+
f"Session {target_id[:8]} is open in another window; waiting for it to be released."
|
|
186
|
+
)
|
|
187
|
+
app.set_status(self.wait_status())
|
|
188
|
+
self._wait_task = asyncio.create_task(self._wait_for_session(target_id))
|
|
189
|
+
app.application.invalidate()
|
|
190
|
+
|
|
191
|
+
async def _wait_for_session(self, target_id: str) -> None:
|
|
192
|
+
app = self._app
|
|
193
|
+
try:
|
|
194
|
+
snapshot = await app._run_blocking(
|
|
195
|
+
app.runner.complete_session_switch, target_id,
|
|
196
|
+
cancelled=lambda: self._wait_cancelled,
|
|
197
|
+
)
|
|
198
|
+
except (KeyError, RuntimeError) as exc:
|
|
199
|
+
self._wait_target = None
|
|
200
|
+
app.state.add_system(str(exc), error=True)
|
|
201
|
+
app.set_status("Session switch failed")
|
|
202
|
+
return
|
|
203
|
+
self._wait_target = None
|
|
204
|
+
if snapshot is None:
|
|
205
|
+
# Cancelled: the runner stays detached, so the picker (or /new) must
|
|
206
|
+
# choose the next session explicitly.
|
|
207
|
+
app.set_status("Waiting cancelled · pick a session")
|
|
208
|
+
await self.resume()
|
|
209
|
+
return
|
|
210
|
+
self.apply_snapshot(snapshot)
|
|
211
|
+
|
|
212
|
+
def _sessions_with_content(self, *, limit: int = 50) -> list[Any]:
|
|
213
|
+
app = self._app
|
|
214
|
+
if app.runner.session_store is None:
|
|
215
|
+
return []
|
|
216
|
+
sessions = []
|
|
217
|
+
for info in app.runner.list_sessions(limit=limit):
|
|
218
|
+
if not app.runner.thread_has_content(info.id):
|
|
219
|
+
continue
|
|
220
|
+
sessions.append(info)
|
|
221
|
+
return sessions
|
agent/cli/state.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any, Callable
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
from agent.attachments import ImageAttachmentRef
|
|
9
|
+
from agent.runner import RunEvent, RunResult, TurnTiming
|
|
10
|
+
from agent.session import TranscriptBlock
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class MessageBlock:
|
|
15
|
+
kind: str
|
|
16
|
+
content: str = ""
|
|
17
|
+
thinking: str = ""
|
|
18
|
+
id: str = field(default_factory=lambda: str(uuid4()))
|
|
19
|
+
is_error: bool = False
|
|
20
|
+
pending: bool = False
|
|
21
|
+
attachments: tuple[ImageAttachmentRef, ...] = ()
|
|
22
|
+
# Bumped on every mutation so the transcript renderer can tell a frozen
|
|
23
|
+
# block from the one still being streamed into.
|
|
24
|
+
revision: int = 0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ToolBlock:
|
|
29
|
+
tool_call_id: str
|
|
30
|
+
name: str
|
|
31
|
+
arguments: dict[str, Any]
|
|
32
|
+
output: str = ""
|
|
33
|
+
# Raw ToolMessage.artifact: the structured tool result, preserved for
|
|
34
|
+
# preview builders. Not persisted as a derived preview.
|
|
35
|
+
artifact: Any = None
|
|
36
|
+
status: str = "running"
|
|
37
|
+
is_error: bool = False
|
|
38
|
+
revision: int = 0
|
|
39
|
+
exit_code: int | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class TurnSummaryBlock:
|
|
44
|
+
elapsed_seconds: float
|
|
45
|
+
finished_at: datetime
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
Block = MessageBlock | ToolBlock | TurnSummaryBlock
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def touch(block: MessageBlock | ToolBlock) -> MessageBlock | ToolBlock:
|
|
52
|
+
"""Mark a block as changed so its cached rendering is rebuilt."""
|
|
53
|
+
block.revision += 1
|
|
54
|
+
return block
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class CliState:
|
|
59
|
+
blocks: list[Block] = field(default_factory=list)
|
|
60
|
+
todos: list[dict[str, str]] = field(default_factory=list)
|
|
61
|
+
running: bool = False
|
|
62
|
+
status: str = "Ready"
|
|
63
|
+
thinking_collapsed: bool = False
|
|
64
|
+
tools_expanded: bool = False
|
|
65
|
+
# Held by reference, not index: app-side filtering of `blocks` would
|
|
66
|
+
# otherwise silently redirect stream deltas into an unrelated block.
|
|
67
|
+
active_block: MessageBlock | None = None
|
|
68
|
+
pending_user_ids: dict[str, str] = field(default_factory=dict)
|
|
69
|
+
attachments: list[ImageAttachmentRef] = field(default_factory=list)
|
|
70
|
+
usage: dict[str, int] = field(default_factory=dict)
|
|
71
|
+
|
|
72
|
+
def add_user(
|
|
73
|
+
self,
|
|
74
|
+
text: str,
|
|
75
|
+
*,
|
|
76
|
+
pending: bool = False,
|
|
77
|
+
message_id: str = "",
|
|
78
|
+
attachments: tuple[ImageAttachmentRef, ...] = (),
|
|
79
|
+
) -> None:
|
|
80
|
+
block = MessageBlock(
|
|
81
|
+
kind="user", content=text, pending=pending,
|
|
82
|
+
id=message_id or str(uuid4()), attachments=attachments,
|
|
83
|
+
)
|
|
84
|
+
self.blocks.append(block)
|
|
85
|
+
self.active_block = None
|
|
86
|
+
if pending and message_id:
|
|
87
|
+
self.pending_user_ids[message_id] = block.id
|
|
88
|
+
|
|
89
|
+
def add_system(self, text: str, *, error: bool = False) -> None:
|
|
90
|
+
self.blocks.append(MessageBlock(kind="error" if error else "system", content=text, is_error=error))
|
|
91
|
+
|
|
92
|
+
def clear(self) -> None:
|
|
93
|
+
self.blocks.clear()
|
|
94
|
+
self.todos.clear()
|
|
95
|
+
self.active_block = None
|
|
96
|
+
self.pending_user_ids.clear()
|
|
97
|
+
self.usage.clear()
|
|
98
|
+
|
|
99
|
+
def load_transcript(self, blocks: list[TranscriptBlock]) -> None:
|
|
100
|
+
self.clear()
|
|
101
|
+
for item in blocks:
|
|
102
|
+
if item.kind == "user":
|
|
103
|
+
self.blocks.append(MessageBlock(
|
|
104
|
+
kind="user", content=item.content, attachments=item.attachments,
|
|
105
|
+
))
|
|
106
|
+
elif item.kind == "assistant":
|
|
107
|
+
self.blocks.append(MessageBlock(
|
|
108
|
+
kind="assistant", content=item.content, thinking=item.thinking,
|
|
109
|
+
))
|
|
110
|
+
elif item.kind == "tool":
|
|
111
|
+
self.blocks.append(ToolBlock(
|
|
112
|
+
tool_call_id=item.tool_call_id,
|
|
113
|
+
name=item.name or "tool",
|
|
114
|
+
arguments=item.arguments or {},
|
|
115
|
+
output=item.content,
|
|
116
|
+
artifact=item.artifact,
|
|
117
|
+
status=item.status or ("error" if item.is_error else "completed"),
|
|
118
|
+
is_error=item.is_error,
|
|
119
|
+
exit_code=item.exit_code,
|
|
120
|
+
))
|
|
121
|
+
|
|
122
|
+
def apply(self, event: RunEvent) -> None:
|
|
123
|
+
if event.type == "run_started":
|
|
124
|
+
self.running = True
|
|
125
|
+
self.status = "Working… Esc to cancel"
|
|
126
|
+
self.active_block = None
|
|
127
|
+
elif event.type == "assistant_started":
|
|
128
|
+
self.active_block = None
|
|
129
|
+
elif event.type == "todos_updated" and isinstance(event.result, list):
|
|
130
|
+
self.todos = [dict(item) for item in event.result if isinstance(item, dict)]
|
|
131
|
+
elif event.type == "usage" and isinstance(event.result, dict):
|
|
132
|
+
usage = {
|
|
133
|
+
key: int(value)
|
|
134
|
+
for key, value in event.result.items()
|
|
135
|
+
if key in {"input_tokens", "output_tokens", "total_tokens"}
|
|
136
|
+
and isinstance(value, int)
|
|
137
|
+
}
|
|
138
|
+
if usage:
|
|
139
|
+
self.usage = usage
|
|
140
|
+
elif event.type in {"thinking_delta", "assistant_delta"}:
|
|
141
|
+
block = touch(self._assistant_block())
|
|
142
|
+
if event.type == "thinking_delta":
|
|
143
|
+
block.thinking = event.content
|
|
144
|
+
else:
|
|
145
|
+
block.content = event.content
|
|
146
|
+
elif event.type == "tool_started":
|
|
147
|
+
tool = self._tool_by_id(event.tool_call_id)
|
|
148
|
+
if tool is None:
|
|
149
|
+
self.blocks.append(ToolBlock(
|
|
150
|
+
tool_call_id=event.tool_call_id,
|
|
151
|
+
name=event.name,
|
|
152
|
+
arguments=event.arguments,
|
|
153
|
+
))
|
|
154
|
+
elif tool.status not in {"completed", "error"}:
|
|
155
|
+
touch(tool)
|
|
156
|
+
tool.name = event.name or tool.name
|
|
157
|
+
tool.arguments = event.arguments or tool.arguments
|
|
158
|
+
tool.status = "running"
|
|
159
|
+
self.active_block = None
|
|
160
|
+
elif event.type == "tool_output_delta":
|
|
161
|
+
tool = self._tool(event.tool_call_id) or self._running_tool()
|
|
162
|
+
if tool is None:
|
|
163
|
+
tool = ToolBlock(event.tool_call_id, event.name or "tool", {})
|
|
164
|
+
self.blocks.append(tool)
|
|
165
|
+
touch(tool)
|
|
166
|
+
if event.stream == "tail_snapshot" and tool.name == "execute":
|
|
167
|
+
tool.output = event.content
|
|
168
|
+
artifact = tool.artifact if isinstance(tool.artifact, dict) else {}
|
|
169
|
+
tool.artifact = {**artifact, "truncated": True}
|
|
170
|
+
else:
|
|
171
|
+
tool.output += event.content
|
|
172
|
+
elif event.type == "assistant_completed":
|
|
173
|
+
self.active_block = None
|
|
174
|
+
elif event.type == "tool_completed":
|
|
175
|
+
tool = self._tool(event.tool_call_id)
|
|
176
|
+
if tool is None:
|
|
177
|
+
tool = ToolBlock(event.tool_call_id, event.name, {})
|
|
178
|
+
self.blocks.append(tool)
|
|
179
|
+
touch(tool)
|
|
180
|
+
artifact = event.artifact if isinstance(event.artifact, dict) else None
|
|
181
|
+
had_tail_snapshot = (
|
|
182
|
+
event.name == "execute" and isinstance(tool.artifact, dict)
|
|
183
|
+
and tool.artifact.get("truncated") is True
|
|
184
|
+
)
|
|
185
|
+
if artifact is not None:
|
|
186
|
+
tool.artifact = artifact
|
|
187
|
+
if event.name == "execute" and artifact is not None:
|
|
188
|
+
if artifact.get("truncated") and not had_tail_snapshot and event.content:
|
|
189
|
+
# Legacy/no-stream fallback: the final result has the tail.
|
|
190
|
+
tool.output = event.content
|
|
191
|
+
elif not tool.output:
|
|
192
|
+
tool.output = event.content
|
|
193
|
+
elif event.content and (not tool.output or event.content.startswith(tool.output)):
|
|
194
|
+
tool.output = event.content
|
|
195
|
+
tool.is_error = event.is_error
|
|
196
|
+
tool.status = "error" if event.is_error else "completed"
|
|
197
|
+
if event.name == "execute" and artifact is not None:
|
|
198
|
+
code = artifact.get("exit_code")
|
|
199
|
+
tool.exit_code = code if isinstance(code, int) and not isinstance(code, bool) else None
|
|
200
|
+
elif event.type == "steering_queued":
|
|
201
|
+
mode = ""
|
|
202
|
+
message_id = ""
|
|
203
|
+
if isinstance(event.result, dict):
|
|
204
|
+
mode = str(event.result.get("mode") or "")
|
|
205
|
+
message_id = str(event.result.get("id") or "")
|
|
206
|
+
if mode == "steer" and event.content:
|
|
207
|
+
self.add_user(event.content, pending=True, message_id=message_id)
|
|
208
|
+
self.status = "Steering queued"
|
|
209
|
+
elif mode == "followUp":
|
|
210
|
+
self.status = f"Follow-up queued ({event.content[:40]})"
|
|
211
|
+
elif event.type == "steering_applied":
|
|
212
|
+
message_id = ""
|
|
213
|
+
if isinstance(event.result, dict):
|
|
214
|
+
message_id = str(event.result.get("id") or "")
|
|
215
|
+
block_id = self.pending_user_ids.pop(message_id, None)
|
|
216
|
+
if block_id:
|
|
217
|
+
for block in self.blocks:
|
|
218
|
+
if isinstance(block, MessageBlock) and block.id == block_id:
|
|
219
|
+
touch(block).pending = False
|
|
220
|
+
break
|
|
221
|
+
elif event.type == "run_cancelling":
|
|
222
|
+
self.status = "Cancelling…"
|
|
223
|
+
elif event.type == "interaction_requested":
|
|
224
|
+
self.running = False
|
|
225
|
+
self.status = "Waiting for input"
|
|
226
|
+
self.active_block = None
|
|
227
|
+
settle = _interaction_settlement(event.result)
|
|
228
|
+
for block in self.blocks:
|
|
229
|
+
if isinstance(block, ToolBlock) and block.status == "running":
|
|
230
|
+
touch(block).status = settle(block)
|
|
231
|
+
elif event.type == "run_completed":
|
|
232
|
+
self.running = False
|
|
233
|
+
self.status = "Ready"
|
|
234
|
+
for block in self.blocks:
|
|
235
|
+
if isinstance(block, ToolBlock) and block.status in {"running", "waiting"}:
|
|
236
|
+
touch(block).status = "interrupted"
|
|
237
|
+
if event.content and not self._has_assistant_text(event.content):
|
|
238
|
+
self.blocks.append(MessageBlock(kind="assistant", content=event.content))
|
|
239
|
+
self.active_block = None
|
|
240
|
+
elif event.type == "turn_completed" and isinstance(event.result, TurnTiming):
|
|
241
|
+
if event.content and not self._has_assistant_text(event.content):
|
|
242
|
+
self.blocks.append(MessageBlock(kind="assistant", content=event.content))
|
|
243
|
+
self.blocks.append(TurnSummaryBlock(
|
|
244
|
+
elapsed_seconds=event.result.elapsed_seconds,
|
|
245
|
+
finished_at=event.result.finished_at,
|
|
246
|
+
))
|
|
247
|
+
self.active_block = None
|
|
248
|
+
elif event.type == "run_cancelled":
|
|
249
|
+
self.running = False
|
|
250
|
+
self.status = "Cancelled"
|
|
251
|
+
for block in self.blocks:
|
|
252
|
+
if isinstance(block, ToolBlock) and block.status == "running":
|
|
253
|
+
touch(block)
|
|
254
|
+
block.status = "error"
|
|
255
|
+
block.is_error = True
|
|
256
|
+
block.exit_code = 1
|
|
257
|
+
if block.name == "execute":
|
|
258
|
+
artifact = block.artifact if isinstance(block.artifact, dict) else {}
|
|
259
|
+
block.artifact = {**artifact, "termination_reason": "cancelled"}
|
|
260
|
+
self.add_system("Operation cancelled.", error=True)
|
|
261
|
+
elif event.type == "run_failed":
|
|
262
|
+
self.running = False
|
|
263
|
+
self.status = "Failed"
|
|
264
|
+
for block in self.blocks:
|
|
265
|
+
if isinstance(block, ToolBlock) and block.status in {"running", "waiting"}:
|
|
266
|
+
touch(block).status = "error"
|
|
267
|
+
block.is_error = True
|
|
268
|
+
block.exit_code = 1
|
|
269
|
+
self.add_system(event.content or "Unknown error", error=True)
|
|
270
|
+
|
|
271
|
+
def _assistant_block(self) -> MessageBlock:
|
|
272
|
+
block = self.active_block
|
|
273
|
+
if block is not None and any(item is block for item in self.blocks):
|
|
274
|
+
return block
|
|
275
|
+
block = MessageBlock(kind="assistant")
|
|
276
|
+
self.blocks.append(block)
|
|
277
|
+
self.active_block = block
|
|
278
|
+
return block
|
|
279
|
+
|
|
280
|
+
def _tool(self, tool_call_id: str) -> ToolBlock | None:
|
|
281
|
+
if not tool_call_id:
|
|
282
|
+
return self._running_tool()
|
|
283
|
+
for block in reversed(self.blocks):
|
|
284
|
+
if isinstance(block, ToolBlock) and block.tool_call_id == tool_call_id:
|
|
285
|
+
return block
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
def _tool_by_id(self, tool_call_id: str) -> ToolBlock | None:
|
|
289
|
+
"""Exact identity lookup; never falls back to an unrelated running tool."""
|
|
290
|
+
if not tool_call_id:
|
|
291
|
+
return None
|
|
292
|
+
for block in reversed(self.blocks):
|
|
293
|
+
if isinstance(block, ToolBlock) and block.tool_call_id == tool_call_id:
|
|
294
|
+
return block
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
def _running_tool(self) -> ToolBlock | None:
|
|
298
|
+
for block in reversed(self.blocks):
|
|
299
|
+
if isinstance(block, ToolBlock) and block.status == "running":
|
|
300
|
+
return block
|
|
301
|
+
return None
|
|
302
|
+
|
|
303
|
+
def _has_assistant_text(self, text: str) -> bool:
|
|
304
|
+
return any(
|
|
305
|
+
isinstance(block, MessageBlock) and block.kind == "assistant" and block.content == text
|
|
306
|
+
for block in self.blocks
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _interaction_settlement(result: Any) -> Callable[[ToolBlock], str]:
|
|
311
|
+
"""Classify running tools at an interrupt as waiting or interrupted.
|
|
312
|
+
|
|
313
|
+
Only the calls the interrupt actually blocks keep the ``waiting`` label;
|
|
314
|
+
every other unfinished call merely lost its completion confirmation.
|
|
315
|
+
"""
|
|
316
|
+
status = str(getattr(result, "status", "") or "")
|
|
317
|
+
if status == "waiting_confirmation":
|
|
318
|
+
pending = {
|
|
319
|
+
str(call.get("toolCallId") or "")
|
|
320
|
+
for call in getattr(result, "pending_tool_calls", None) or []
|
|
321
|
+
if isinstance(call, dict)
|
|
322
|
+
}
|
|
323
|
+
return lambda block: "waiting" if block.tool_call_id in pending else "interrupted"
|
|
324
|
+
if status == "waiting_human":
|
|
325
|
+
return lambda block: "waiting" if block.name == "request_human_input" else "interrupted"
|
|
326
|
+
return lambda _block: "interrupted"
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# DeepAgent template configuration.
|
|
2
|
+
# First CLI launch copies this template to ~/.deep-agent/config.yaml and exits.
|
|
3
|
+
#
|
|
4
|
+
# Override path with DEEP_AGENT_CONFIG=/path/outside/workspace/config.yaml
|
|
5
|
+
|
|
6
|
+
agent:
|
|
7
|
+
# Long-term user instructions appended after the built-in identity.
|
|
8
|
+
# instructions: |
|
|
9
|
+
# 优先使用中文回答。
|
|
10
|
+
instructions: null
|
|
11
|
+
|
|
12
|
+
ui:
|
|
13
|
+
# IANA time zone for the time shown after each completed answer.
|
|
14
|
+
timezone: Asia/Shanghai
|
|
15
|
+
|
|
16
|
+
llm:
|
|
17
|
+
# Required for grouped models: source/model. Change this to select the startup model.
|
|
18
|
+
default: local/qwen-plus
|
|
19
|
+
models:
|
|
20
|
+
local:
|
|
21
|
+
# A literal key or a complete environment reference such as ${MODEL_API_KEY}.
|
|
22
|
+
api_key: sk-local
|
|
23
|
+
base_url: http://localhost:8000/v1
|
|
24
|
+
provider: qwen-responses
|
|
25
|
+
stream_usage: false
|
|
26
|
+
models:
|
|
27
|
+
qwen-plus:
|
|
28
|
+
model: qwen3.5-plus
|
|
29
|
+
input: [text, image]
|
|
30
|
+
context_window: 1m
|
|
31
|
+
qwen3.6-flash:
|
|
32
|
+
input: [text, image]
|
|
33
|
+
context_window: 1m
|
|
34
|
+
# --- Aliyun Bailian Qwen Token Plan (compatible-mode endpoint) ---
|
|
35
|
+
# Uncomment this group and set default: token-plan/auto to start with Token Plan.
|
|
36
|
+
# token-plan:
|
|
37
|
+
# api_key: ${TOKEN_PLAN_API_KEY}
|
|
38
|
+
# base_url: https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1
|
|
39
|
+
# provider: openai-compatible
|
|
40
|
+
# stream_usage: true
|
|
41
|
+
# context_window: 1m
|
|
42
|
+
# models:
|
|
43
|
+
# auto:
|
|
44
|
+
# input: [text]
|
|
45
|
+
# qwen3.8-max:
|
|
46
|
+
# input: [text, image]
|
|
47
|
+
# qwen3.8-flash:
|
|
48
|
+
# input: [text, image]
|
|
49
|
+
|
|
50
|
+
web_search:
|
|
51
|
+
# Optional. TAVILY_API_KEY overrides this value; omit both to hide web_search.
|
|
52
|
+
# This field also accepts a literal key in your private ~/.deep-agent/config.yaml.
|
|
53
|
+
tavily_api_key: null
|
|
54
|
+
|
|
55
|
+
sandbox:
|
|
56
|
+
# Host directory mapped to /workspace. "." = the directory you start the TUI from (cwd).
|
|
57
|
+
workspace: .
|
|
58
|
+
bwrap_path: bwrap
|
|
59
|
+
# Allow dangerous host execution when bubblewrap is unavailable.
|
|
60
|
+
allow_unsandboxed: false
|
|
61
|
+
# No time limit by default. Set a positive number to cap each command.
|
|
62
|
+
timeout_seconds: null
|
|
63
|
+
max_output_bytes: 100000
|
|
64
|
+
# Host env var names explicitly exposed to Agent commands.
|
|
65
|
+
env_allowlist: []
|
|
66
|
+
# Fixed env vars injected into the sandbox (not read from the host).
|
|
67
|
+
env_set: {}
|
|
68
|
+
# Extra mounts: [{source: /host/path, destination: /sandbox/path}, ...]
|
|
69
|
+
extra_read_only_mounts: []
|
|
70
|
+
extra_read_write_mounts: []
|
|
71
|
+
|
|
72
|
+
paths:
|
|
73
|
+
# SQLite session DB. null → ~/.deep-agent/sessions/<workspace-hash>.sqlite3
|
|
74
|
+
state_path: null
|
|
75
|
+
# Keybindings directory. null → ~/.deep-agent
|
|
76
|
+
config_dir: null
|