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.
Files changed (50) hide show
  1. agent/__init__.py +42 -0
  2. agent/attachments.py +303 -0
  3. agent/bootstrap.py +44 -0
  4. agent/cancel.py +107 -0
  5. agent/cli/__init__.py +5 -0
  6. agent/cli/app.py +1768 -0
  7. agent/cli/clipboard.py +224 -0
  8. agent/cli/commands.py +94 -0
  9. agent/cli/gitinfo.py +84 -0
  10. agent/cli/input.py +65 -0
  11. agent/cli/interactions.py +187 -0
  12. agent/cli/main.py +124 -0
  13. agent/cli/previews.py +710 -0
  14. agent/cli/rendering.py +770 -0
  15. agent/cli/session_controller.py +221 -0
  16. agent/cli/state.py +326 -0
  17. agent/config.example.yaml +76 -0
  18. agent/config.py +528 -0
  19. agent/control.py +171 -0
  20. agent/factory.py +232 -0
  21. agent/file_mutation.py +5 -0
  22. agent/llm.py +339 -0
  23. agent/middleware/__init__.py +9 -0
  24. agent/middleware/attachments.py +31 -0
  25. agent/middleware/cancel_tools.py +39 -0
  26. agent/middleware/pause.py +18 -0
  27. agent/middleware/recovery.py +65 -0
  28. agent/middleware/steering.py +35 -0
  29. agent/middleware/tool_arg_hints.py +128 -0
  30. agent/middleware/workspace_filesystem.py +38 -0
  31. agent/middleware/write_operation.py +60 -0
  32. agent/network.py +30 -0
  33. agent/permission.py +80 -0
  34. agent/runner.py +1393 -0
  35. agent/sandbox.py +699 -0
  36. agent/session.py +431 -0
  37. agent/session_lock.py +223 -0
  38. agent/session_runtime.py +209 -0
  39. agent/stream.py +168 -0
  40. agent/tools/__init__.py +9 -0
  41. agent/tools/examples.py +30 -0
  42. agent/tools/execute.py +73 -0
  43. agent/tools/human_input.py +170 -0
  44. agent/tools/human_interaction.py +101 -0
  45. agent/tools/web_search.py +131 -0
  46. deep_agent_cli-0.1.0.dist-info/METADATA +408 -0
  47. deep_agent_cli-0.1.0.dist-info/RECORD +50 -0
  48. deep_agent_cli-0.1.0.dist-info/WHEEL +4 -0
  49. deep_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
  50. 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