bareagent-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 (121) hide show
  1. bareagent/__init__.py +10 -0
  2. bareagent/concurrency/__init__.py +6 -0
  3. bareagent/concurrency/background.py +97 -0
  4. bareagent/concurrency/notification.py +61 -0
  5. bareagent/concurrency/scheduler.py +136 -0
  6. bareagent/config.toml +299 -0
  7. bareagent/core/__init__.py +1 -0
  8. bareagent/core/config_paths.py +49 -0
  9. bareagent/core/context.py +127 -0
  10. bareagent/core/fileutil.py +103 -0
  11. bareagent/core/goal.py +214 -0
  12. bareagent/core/handlers/__init__.py +1 -0
  13. bareagent/core/handlers/bash.py +79 -0
  14. bareagent/core/handlers/file_edit.py +47 -0
  15. bareagent/core/handlers/file_read.py +270 -0
  16. bareagent/core/handlers/file_write.py +34 -0
  17. bareagent/core/handlers/glob_search.py +30 -0
  18. bareagent/core/handlers/goal.py +60 -0
  19. bareagent/core/handlers/grep_search.py +52 -0
  20. bareagent/core/handlers/memory.py +71 -0
  21. bareagent/core/handlers/plan.py +106 -0
  22. bareagent/core/handlers/search_utils.py +77 -0
  23. bareagent/core/handlers/skill.py +87 -0
  24. bareagent/core/handlers/subagent_send.py +70 -0
  25. bareagent/core/handlers/web_fetch.py +126 -0
  26. bareagent/core/handlers/web_search.py +165 -0
  27. bareagent/core/handlers/workflow.py +190 -0
  28. bareagent/core/loop.py +535 -0
  29. bareagent/core/retry.py +131 -0
  30. bareagent/core/sandbox.py +27 -0
  31. bareagent/core/schema.py +21 -0
  32. bareagent/core/tools.py +779 -0
  33. bareagent/core/workflow.py +517 -0
  34. bareagent/core/workflow_registry.py +219 -0
  35. bareagent/debug/__init__.py +0 -0
  36. bareagent/debug/interaction_log.py +263 -0
  37. bareagent/debug/viewer.html +1750 -0
  38. bareagent/debug/web_viewer.py +157 -0
  39. bareagent/hooks/__init__.py +32 -0
  40. bareagent/hooks/config.py +118 -0
  41. bareagent/hooks/engine.py +197 -0
  42. bareagent/hooks/errors.py +14 -0
  43. bareagent/hooks/events.py +22 -0
  44. bareagent/lsp/__init__.py +63 -0
  45. bareagent/lsp/config.py +134 -0
  46. bareagent/lsp/coord.py +118 -0
  47. bareagent/lsp/diagnostics.py +240 -0
  48. bareagent/lsp/errors.py +24 -0
  49. bareagent/lsp/manager.py +866 -0
  50. bareagent/lsp/tools.py +629 -0
  51. bareagent/lsp/workspace_edit.py +305 -0
  52. bareagent/main.py +4205 -0
  53. bareagent/mcp/__init__.py +69 -0
  54. bareagent/mcp/_sse.py +69 -0
  55. bareagent/mcp/client.py +341 -0
  56. bareagent/mcp/config.py +169 -0
  57. bareagent/mcp/errors.py +32 -0
  58. bareagent/mcp/manager.py +318 -0
  59. bareagent/mcp/protocol.py +187 -0
  60. bareagent/mcp/registry.py +557 -0
  61. bareagent/mcp/transport/__init__.py +15 -0
  62. bareagent/mcp/transport/base.py +149 -0
  63. bareagent/mcp/transport/http_legacy.py +192 -0
  64. bareagent/mcp/transport/http_streamable.py +217 -0
  65. bareagent/mcp/transport/stdio.py +202 -0
  66. bareagent/memory/__init__.py +1 -0
  67. bareagent/memory/compact.py +203 -0
  68. bareagent/memory/conversation_io.py +226 -0
  69. bareagent/memory/embedding.py +194 -0
  70. bareagent/memory/persistent.py +515 -0
  71. bareagent/memory/token_counter.py +67 -0
  72. bareagent/memory/token_tracker.py +262 -0
  73. bareagent/memory/transcript.py +100 -0
  74. bareagent/permission/__init__.py +1 -0
  75. bareagent/permission/guard.py +329 -0
  76. bareagent/permission/rules.py +19 -0
  77. bareagent/planning/__init__.py +19 -0
  78. bareagent/planning/agent_types.py +169 -0
  79. bareagent/planning/skill_gen.py +141 -0
  80. bareagent/planning/skill_store.py +173 -0
  81. bareagent/planning/skills.py +146 -0
  82. bareagent/planning/subagent.py +355 -0
  83. bareagent/planning/subagent_registry.py +77 -0
  84. bareagent/planning/tasks.py +348 -0
  85. bareagent/planning/todo.py +153 -0
  86. bareagent/planning/worktree.py +122 -0
  87. bareagent/provider/__init__.py +1 -0
  88. bareagent/provider/anthropic.py +348 -0
  89. bareagent/provider/base.py +136 -0
  90. bareagent/provider/factory.py +130 -0
  91. bareagent/provider/openai.py +881 -0
  92. bareagent/provider/presets.py +72 -0
  93. bareagent/provider/setup.py +356 -0
  94. bareagent/skills/.gitkeep +1 -0
  95. bareagent/skills/code-review/SKILL.md +68 -0
  96. bareagent/skills/git/SKILL.md +68 -0
  97. bareagent/skills/test/SKILL.md +70 -0
  98. bareagent/team/__init__.py +17 -0
  99. bareagent/team/autonomous.py +193 -0
  100. bareagent/team/mailbox.py +239 -0
  101. bareagent/team/manager.py +155 -0
  102. bareagent/team/protocols.py +129 -0
  103. bareagent/tracing/__init__.py +12 -0
  104. bareagent/tracing/_api.py +92 -0
  105. bareagent/tracing/_proxy.py +60 -0
  106. bareagent/tracing/composite.py +115 -0
  107. bareagent/tracing/json_file.py +115 -0
  108. bareagent/tracing/langfuse.py +139 -0
  109. bareagent/tracing/otel.py +107 -0
  110. bareagent/tracing/setup.py +85 -0
  111. bareagent/ui/__init__.py +24 -0
  112. bareagent/ui/console.py +167 -0
  113. bareagent/ui/prompt.py +78 -0
  114. bareagent/ui/protocol.py +24 -0
  115. bareagent/ui/stream.py +66 -0
  116. bareagent/ui/theme.py +240 -0
  117. bareagent_cli-0.1.0.dist-info/METADATA +331 -0
  118. bareagent_cli-0.1.0.dist-info/RECORD +121 -0
  119. bareagent_cli-0.1.0.dist-info/WHEEL +4 -0
  120. bareagent_cli-0.1.0.dist-info/entry_points.txt +2 -0
  121. bareagent_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,779 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ from collections.abc import Callable
5
+ from functools import partial
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from bareagent.concurrency.background import BackgroundManager
10
+ from bareagent.core.fileutil import generate_random_id
11
+ from bareagent.core.handlers.bash import run_bash
12
+ from bareagent.core.handlers.file_edit import run_edit
13
+ from bareagent.core.handlers.file_read import run_read
14
+ from bareagent.core.handlers.file_write import run_write
15
+ from bareagent.core.handlers.glob_search import run_glob
16
+ from bareagent.core.handlers.grep_search import run_grep
17
+ from bareagent.core.handlers.memory import run_memory
18
+ from bareagent.core.handlers.web_fetch import run_web_fetch
19
+ from bareagent.core.handlers.web_search import run_web_search
20
+ from bareagent.core.schema import tool_schema as _schema
21
+ from bareagent.lsp.manager import LanguageServerManager
22
+ from bareagent.lsp.tools import (
23
+ LSP_TOOL_SCHEMAS,
24
+ SEMANTIC_RENAME_TOOL_NAME,
25
+ SEMANTIC_RENAME_TOOL_SCHEMA,
26
+ build_lsp_tools,
27
+ )
28
+ from bareagent.mcp.manager import MCPManager
29
+ from bareagent.mcp.registry import build_mcp_handlers, build_mcp_tool_schemas
30
+ from bareagent.memory.persistent import MemoryManager
31
+ from bareagent.planning.skills import (
32
+ LOAD_SKILL_TOOL_SCHEMAS,
33
+ SkillLoader,
34
+ make_skill_handlers,
35
+ resolve_skills_dir,
36
+ )
37
+ from bareagent.planning.subagent import SUBAGENT_TOOL_SCHEMAS, run_subagent
38
+ from bareagent.planning.tasks import TASK_TOOL_SCHEMAS, TaskManager, make_task_handlers
39
+ from bareagent.planning.todo import TODO_TOOL_SCHEMAS, TodoManager, make_todo_handlers
40
+
41
+ BASE_TOOLS = {
42
+ "bash",
43
+ "read_file",
44
+ "write_file",
45
+ "edit_file",
46
+ "glob",
47
+ "grep",
48
+ "web_fetch",
49
+ "web_search",
50
+ }
51
+ DEFERRED_TOOLS = {
52
+ "todo_write",
53
+ "todo_read",
54
+ "subagent",
55
+ "load_skill",
56
+ "task_create",
57
+ "task_list",
58
+ "task_get",
59
+ "task_update",
60
+ "background_run",
61
+ "team_spawn",
62
+ "team_send",
63
+ "team_list",
64
+ "team_shutdown",
65
+ "team_register",
66
+ "team_request_review",
67
+ "lsp_outline",
68
+ "lsp_definition",
69
+ "lsp_references",
70
+ "lsp_diagnostics",
71
+ "memory",
72
+ }
73
+
74
+
75
+ BACKGROUND_TOOL_SCHEMAS: list[dict[str, Any]] = [
76
+ _schema(
77
+ "background_run",
78
+ "Run a shell command in a daemon thread and report the result later.",
79
+ {
80
+ "command": {
81
+ "type": "string",
82
+ "description": "Shell command to execute in the background.",
83
+ },
84
+ "timeout": {
85
+ "type": "integer",
86
+ "description": "Timeout in seconds.",
87
+ "default": 30,
88
+ "minimum": 1,
89
+ },
90
+ "task_id": {
91
+ "type": "string",
92
+ "description": "Optional background task id. Auto-generated when omitted.",
93
+ },
94
+ },
95
+ ["command"],
96
+ ),
97
+ ]
98
+ TEAM_TOOL_SCHEMAS: list[dict[str, Any]] = [
99
+ _schema(
100
+ "team_spawn",
101
+ "Spawn a registered teammate as an autonomous background agent.",
102
+ {
103
+ "name": {
104
+ "type": "string",
105
+ "description": "Registered teammate name.",
106
+ }
107
+ },
108
+ ["name"],
109
+ ),
110
+ _schema(
111
+ "team_send",
112
+ (
113
+ "Send a message to a running teammate and wait for its reply, which is "
114
+ "returned to you. If the teammate is not running (or the target is the "
115
+ "main agent), returns immediately without waiting."
116
+ ),
117
+ {
118
+ "to_agent": {
119
+ "type": "string",
120
+ "description": "Target teammate name.",
121
+ },
122
+ "content": {
123
+ "type": "string",
124
+ "description": "Message content.",
125
+ },
126
+ },
127
+ ["to_agent", "content"],
128
+ ),
129
+ _schema(
130
+ "team_list",
131
+ "List registered teammates and whether each is currently running.",
132
+ {},
133
+ [],
134
+ ),
135
+ _schema(
136
+ "team_shutdown",
137
+ "Stop a single running teammate (sends a shutdown signal to its mailbox).",
138
+ {
139
+ "name": {
140
+ "type": "string",
141
+ "description": "Teammate name to stop.",
142
+ }
143
+ },
144
+ ["name"],
145
+ ),
146
+ _schema(
147
+ "team_register",
148
+ (
149
+ "Register a new teammate definition (persisted to .team.json) so it can "
150
+ "later be spawned with team_spawn. This only defines the teammate; it does "
151
+ "not start it. Omit provider/model to inherit the session provider."
152
+ ),
153
+ {
154
+ "name": {
155
+ "type": "string",
156
+ "description": "Unique teammate name.",
157
+ },
158
+ "role": {
159
+ "type": "string",
160
+ "description": "Short role label, e.g. 'code reviewer'.",
161
+ },
162
+ "system_prompt": {
163
+ "type": "string",
164
+ "description": "System prompt defining the teammate's behavior.",
165
+ },
166
+ "provider": {
167
+ "type": "string",
168
+ "description": (
169
+ "Optional LLM provider name (e.g. 'openai', 'anthropic'). "
170
+ "Inherits the session provider when omitted."
171
+ ),
172
+ },
173
+ "model": {
174
+ "type": "string",
175
+ "description": "Optional model id. Inherits the session model when omitted.",
176
+ },
177
+ },
178
+ ["name", "role", "system_prompt"],
179
+ ),
180
+ _schema(
181
+ "team_request_review",
182
+ (
183
+ "Send a plan or proposal to a running teammate for approval and wait for "
184
+ "its verdict, which is returned to you. If the teammate is not running (or "
185
+ "the target is the main agent), returns immediately without waiting."
186
+ ),
187
+ {
188
+ "to_agent": {
189
+ "type": "string",
190
+ "description": "Target teammate name (the reviewer).",
191
+ },
192
+ "plan": {
193
+ "type": "string",
194
+ "description": "The plan or proposal to review and approve/reject.",
195
+ },
196
+ },
197
+ ["to_agent", "plan"],
198
+ ),
199
+ ]
200
+ MEMORY_TOOL_SCHEMAS: list[dict[str, Any]] = [
201
+ _schema(
202
+ "memory",
203
+ (
204
+ "Read and maintain your persistent cross-session memory: a private "
205
+ "directory of Markdown files plus a MEMORY.md index. Paths are "
206
+ 'relative to the memory root (e.g. "MEMORY.md", "user/role.md"). '
207
+ "Sub-agents (explore/plan/code-review) may only use the 'view' command."
208
+ ),
209
+ {
210
+ "command": {
211
+ "type": "string",
212
+ "enum": [
213
+ "view",
214
+ "create",
215
+ "str_replace",
216
+ "insert",
217
+ "delete",
218
+ "rename",
219
+ ],
220
+ "description": "Operation to perform.",
221
+ },
222
+ "path": {
223
+ "type": "string",
224
+ "description": (
225
+ "Target path for view/create/str_replace/insert/delete. "
226
+ "Omit (or '.') to view the memory root directory."
227
+ ),
228
+ },
229
+ "file_text": {
230
+ "type": "string",
231
+ "description": "For create: the full file content to write.",
232
+ },
233
+ "old_str": {
234
+ "type": "string",
235
+ "description": "For str_replace: existing text to replace (must be unique).",
236
+ },
237
+ "new_str": {
238
+ "type": "string",
239
+ "description": "For str_replace: replacement text.",
240
+ },
241
+ "insert_line": {
242
+ "type": "integer",
243
+ "description": "For insert: line number to insert after (0 = file start).",
244
+ "minimum": 0,
245
+ },
246
+ "insert_text": {
247
+ "type": "string",
248
+ "description": "For insert: text to insert.",
249
+ },
250
+ "old_path": {
251
+ "type": "string",
252
+ "description": "For rename: existing path.",
253
+ },
254
+ "new_path": {
255
+ "type": "string",
256
+ "description": "For rename: destination path.",
257
+ },
258
+ "view_range": {
259
+ "type": "array",
260
+ "items": {"type": "integer"},
261
+ "description": (
262
+ "For view: optional [start, end] 1-based inclusive line "
263
+ "range (end -1 = to end of file)."
264
+ ),
265
+ },
266
+ },
267
+ ["command"],
268
+ ),
269
+ ]
270
+ DEFERRED_TOOL_SCHEMAS: list[dict[str, Any]] = [
271
+ *TODO_TOOL_SCHEMAS,
272
+ *SUBAGENT_TOOL_SCHEMAS,
273
+ *LOAD_SKILL_TOOL_SCHEMAS,
274
+ *TASK_TOOL_SCHEMAS,
275
+ *BACKGROUND_TOOL_SCHEMAS,
276
+ *TEAM_TOOL_SCHEMAS,
277
+ *LSP_TOOL_SCHEMAS,
278
+ SEMANTIC_RENAME_TOOL_SCHEMA,
279
+ *MEMORY_TOOL_SCHEMAS,
280
+ ]
281
+
282
+ TOOL_SCHEMAS: list[dict[str, Any]] = [
283
+ _schema(
284
+ "bash",
285
+ "Run a shell command in the current workspace.",
286
+ {
287
+ "command": {"type": "string", "description": "Command to execute."},
288
+ "timeout": {
289
+ "type": "integer",
290
+ "description": "Timeout in seconds.",
291
+ "default": 30,
292
+ "minimum": 1,
293
+ },
294
+ },
295
+ ["command"],
296
+ ),
297
+ _schema(
298
+ "read_file",
299
+ (
300
+ "Read a file. UTF-8 text files are returned with line numbers. "
301
+ "Images (.png/.jpg/.jpeg/.gif/.webp) are returned as image blocks "
302
+ "(needs a vision-capable model). PDFs (.pdf) return extracted text "
303
+ 'and need the optional [pdf] extra (uv pip install -e ".[pdf]"). '
304
+ "Jupyter notebooks (.ipynb) return rendered markdown/code cells."
305
+ ),
306
+ {
307
+ "file_path": {"type": "string", "description": "Path to the file."},
308
+ "offset": {
309
+ "type": "integer",
310
+ "description": "Zero-based line offset (text files only).",
311
+ "default": 0,
312
+ "minimum": 0,
313
+ },
314
+ "limit": {
315
+ "type": ["integer", "null"],
316
+ "description": "Maximum number of lines to read (text files only).",
317
+ "default": None,
318
+ "minimum": 0,
319
+ },
320
+ "pages": {
321
+ "type": ["string", "null"],
322
+ "description": (
323
+ "PDF page range, e.g. '1-5' or '3' (1-based). PDF only; omit for all pages."
324
+ ),
325
+ "default": None,
326
+ },
327
+ },
328
+ ["file_path"],
329
+ ),
330
+ _schema(
331
+ "write_file",
332
+ "Write content to a text file inside the workspace.",
333
+ {
334
+ "file_path": {"type": "string", "description": "Path to the file."},
335
+ "content": {"type": "string", "description": "Content to write."},
336
+ },
337
+ ["file_path", "content"],
338
+ ),
339
+ _schema(
340
+ "edit_file",
341
+ "Replace existing text in a workspace file.",
342
+ {
343
+ "file_path": {"type": "string", "description": "Path to the file."},
344
+ "old_text": {
345
+ "type": "string",
346
+ "description": "Existing text to replace.",
347
+ },
348
+ "new_text": {
349
+ "type": "string",
350
+ "description": "Replacement text.",
351
+ },
352
+ },
353
+ ["file_path", "old_text", "new_text"],
354
+ ),
355
+ _schema(
356
+ "glob",
357
+ "Find files by glob pattern within the workspace.",
358
+ {
359
+ "pattern": {"type": "string", "description": "Glob pattern to match."},
360
+ "path": {
361
+ "type": "string",
362
+ "description": "Directory to search from.",
363
+ "default": ".",
364
+ },
365
+ },
366
+ ["pattern"],
367
+ ),
368
+ _schema(
369
+ "grep",
370
+ "Search file contents with a regular expression.",
371
+ {
372
+ "pattern": {"type": "string", "description": "Regex to search for."},
373
+ "path": {
374
+ "type": "string",
375
+ "description": "Path to search from.",
376
+ "default": ".",
377
+ },
378
+ "include": {
379
+ "type": "string",
380
+ "description": "Optional glob filter for files.",
381
+ "default": "",
382
+ },
383
+ },
384
+ ["pattern"],
385
+ ),
386
+ _schema(
387
+ "web_fetch",
388
+ "Fetch content from a URL. Automatically converts HTML to readable text.",
389
+ {
390
+ "url": {
391
+ "type": "string",
392
+ "description": "URL to fetch (http:// or https://).",
393
+ },
394
+ "max_length": {
395
+ "type": "integer",
396
+ "description": "Maximum characters to return.",
397
+ "default": 10000,
398
+ "minimum": 100,
399
+ },
400
+ "timeout": {
401
+ "type": "integer",
402
+ "description": "Request timeout in seconds.",
403
+ "default": 15,
404
+ "minimum": 1,
405
+ },
406
+ },
407
+ ["url"],
408
+ ),
409
+ _schema(
410
+ "web_search",
411
+ "Search the web and return structured results.",
412
+ {
413
+ "query": {"type": "string", "description": "Search query."},
414
+ "max_results": {
415
+ "type": "integer",
416
+ "description": "Maximum number of results to return.",
417
+ "default": 5,
418
+ "minimum": 1,
419
+ "maximum": 10,
420
+ },
421
+ "timeout": {
422
+ "type": "integer",
423
+ "description": "Request timeout in seconds.",
424
+ "default": 15,
425
+ "minimum": 1,
426
+ },
427
+ },
428
+ ["query"],
429
+ ),
430
+ *DEFERRED_TOOL_SCHEMAS,
431
+ ]
432
+
433
+
434
+ def _build_diagnostics_hook(
435
+ lsp_manager: LanguageServerManager | None,
436
+ ) -> Callable[[str, Any], Any] | None:
437
+ """Construct the Hybrid auto-diagnostics callback for file edit/write.
438
+
439
+ Returns ``None`` when no manager is wired (LSP disabled). When wired,
440
+ the returned closure is invoked twice by the handler:
441
+
442
+ * ``hook(file_path, None)`` — snapshot the current diagnostics so the
443
+ handler can pass them back for diffing. Returns ``list[Diagnostic]``
444
+ or ``None`` if the snapshot is unusable.
445
+ * ``hook(file_path, before)`` — post-edit; returns the formatted
446
+ ``"\\n\\n…"`` appendix or ``None`` (caller appends only on non-None).
447
+
448
+ Heavy lifting is delegated to :func:`bareagent.lsp.diagnostics.snapshot_diagnostics`
449
+ and :func:`bareagent.lsp.diagnostics.maybe_diagnostics_appendix`. Both swallow
450
+ their own errors so the handler never fails because of an LSP hiccup.
451
+ """
452
+ if lsp_manager is None:
453
+ return None
454
+
455
+ from bareagent.lsp.diagnostics import (
456
+ maybe_diagnostics_appendix,
457
+ snapshot_diagnostics,
458
+ )
459
+
460
+ def _hook(file_path: str, before: Any) -> Any:
461
+ # Cheap config gate before touching the manager so disabled mode
462
+ # exits in ~one attribute access.
463
+ try:
464
+ cfg = lsp_manager.config
465
+ except Exception:
466
+ return None
467
+ if not cfg.auto_diagnostics_on_edit:
468
+ return None
469
+ if before is None:
470
+ try:
471
+ return snapshot_diagnostics(lsp_manager, file_path)
472
+ except Exception:
473
+ return None
474
+ return maybe_diagnostics_appendix(lsp_manager, cfg, file_path, before)
475
+
476
+ return _hook
477
+
478
+
479
+ def _make_lazy_task_handlers(task_file: Path) -> dict[str, Callable[..., Any]]:
480
+ state: dict[str, dict[str, Callable[..., Any]] | None] = {"handlers": None}
481
+
482
+ def _get_handlers() -> dict[str, Callable[..., Any]]:
483
+ handlers = state["handlers"]
484
+ if handlers is None:
485
+ handlers = make_task_handlers(TaskManager(task_file))
486
+ state["handlers"] = handlers
487
+ return handlers
488
+
489
+ return {
490
+ "task_create": lambda title, description="", depends_on=None: _get_handlers()[
491
+ "task_create"
492
+ ](
493
+ title=title,
494
+ description=description,
495
+ depends_on=depends_on,
496
+ ),
497
+ "task_update": lambda task_id, status=None, title=None: _get_handlers()["task_update"](
498
+ task_id=task_id,
499
+ status=status,
500
+ title=title,
501
+ ),
502
+ "task_get": lambda task_id: _get_handlers()["task_get"](task_id=task_id),
503
+ "task_list": lambda status=None: _get_handlers()["task_list"](status=status),
504
+ }
505
+
506
+
507
+ _DEFAULT_TODO_MANAGER: TodoManager | None = None
508
+ _DEFAULT_SKILL_LOADER: SkillLoader | None = None
509
+ _SINGLETON_LOCK = threading.Lock()
510
+
511
+
512
+ def _get_default_todo_manager() -> TodoManager:
513
+ global _DEFAULT_TODO_MANAGER
514
+ if _DEFAULT_TODO_MANAGER is None:
515
+ with _SINGLETON_LOCK:
516
+ if _DEFAULT_TODO_MANAGER is None:
517
+ _DEFAULT_TODO_MANAGER = TodoManager()
518
+ return _DEFAULT_TODO_MANAGER
519
+
520
+
521
+ def _get_default_skill_loader() -> SkillLoader:
522
+ global _DEFAULT_SKILL_LOADER
523
+ if _DEFAULT_SKILL_LOADER is None:
524
+ with _SINGLETON_LOCK:
525
+ if _DEFAULT_SKILL_LOADER is None:
526
+ _DEFAULT_SKILL_LOADER = SkillLoader(resolve_skills_dir())
527
+ return _DEFAULT_SKILL_LOADER
528
+
529
+
530
+ def _unbound_stub(tool_name: str) -> Callable[..., Any]:
531
+ """Raise when a file/bash handler is called without workspace binding."""
532
+
533
+ def _stub(**_: Any) -> str:
534
+ raise RuntimeError(f"{tool_name}: use get_handlers() with a workspace binding")
535
+
536
+ return _stub
537
+
538
+
539
+ _TEAM_FALLBACK_HANDLERS: dict[str, Callable[..., Any]] = {
540
+ "team_spawn": lambda name: f"Team spawning unavailable for {name}.",
541
+ "team_send": lambda to_agent, content: f"Team messaging unavailable for {to_agent}.",
542
+ "team_list": lambda: [],
543
+ "team_shutdown": lambda name: f"Team shutdown unavailable for {name}.",
544
+ "team_register": lambda name, role, system_prompt, provider="", model="": (
545
+ f"Team registration unavailable for {name}."
546
+ ),
547
+ "team_request_review": lambda to_agent, plan: (
548
+ f"Team review unavailable for {to_agent}."
549
+ ),
550
+ }
551
+
552
+
553
+ _LSP_UNAVAILABLE_MESSAGE = "Error: LSP manager unavailable."
554
+
555
+ _LSP_FALLBACK_HANDLERS: dict[str, Callable[..., Any]] = {
556
+ "lsp_outline": lambda **_kw: _LSP_UNAVAILABLE_MESSAGE,
557
+ "lsp_definition": lambda **_kw: _LSP_UNAVAILABLE_MESSAGE,
558
+ "lsp_references": lambda **_kw: _LSP_UNAVAILABLE_MESSAGE,
559
+ "lsp_diagnostics": lambda **_kw: _LSP_UNAVAILABLE_MESSAGE,
560
+ SEMANTIC_RENAME_TOOL_NAME: lambda **_kw: _LSP_UNAVAILABLE_MESSAGE,
561
+ }
562
+
563
+ _MEMORY_DISABLED_MESSAGE = (
564
+ "Error: persistent memory is disabled. Enable it under [memory] in config."
565
+ )
566
+
567
+
568
+ def _memory_disabled_handler(**_kw: Any) -> str:
569
+ return _MEMORY_DISABLED_MESSAGE
570
+
571
+
572
+ TOOL_HANDLERS: dict[str, Callable[..., Any]] = {
573
+ "bash": _unbound_stub("bash"),
574
+ "read_file": _unbound_stub("read_file"),
575
+ "write_file": _unbound_stub("write_file"),
576
+ "edit_file": _unbound_stub("edit_file"),
577
+ "glob": _unbound_stub("glob"),
578
+ "grep": _unbound_stub("grep"),
579
+ "web_fetch": run_web_fetch,
580
+ "web_search": run_web_search,
581
+ "todo_read": lambda: _get_default_todo_manager().list(),
582
+ "todo_write": lambda **kw: make_todo_handlers(_get_default_todo_manager())["todo_write"](**kw),
583
+ **_make_lazy_task_handlers(Path(".tasks.json")),
584
+ "load_skill": lambda skill_name: _get_default_skill_loader().load(skill_name),
585
+ "background_run": lambda **_: "Background manager unavailable.",
586
+ "subagent": (
587
+ lambda task, agent_type=None, run_in_background=False: (
588
+ "Subagent unavailable: provider is not configured."
589
+ )
590
+ ),
591
+ **_TEAM_FALLBACK_HANDLERS,
592
+ **_LSP_FALLBACK_HANDLERS,
593
+ "memory": _memory_disabled_handler,
594
+ }
595
+
596
+
597
+ def get_tools(
598
+ mcp_manager: MCPManager | None = None,
599
+ lsp_manager: LanguageServerManager | None = None,
600
+ ) -> list[dict[str, Any]]:
601
+ # LSP tool schemas are already part of TOOL_SCHEMAS (registered via
602
+ # ``DEFERRED_TOOL_SCHEMAS``) so they show up even when no manager is
603
+ # available. ``lsp_manager`` is still required for the handlers — that is
604
+ # bound by ``get_handlers``. We accept the parameter here for forward
605
+ # symmetry with ``mcp_manager`` and so callers can supply both in one go.
606
+ _ = lsp_manager
607
+ schemas = list(TOOL_SCHEMAS)
608
+ if mcp_manager is not None:
609
+ schemas.extend(build_mcp_tool_schemas(mcp_manager))
610
+ return schemas
611
+
612
+
613
+ def get_handlers(
614
+ workspace: Path,
615
+ *,
616
+ todo_manager: TodoManager | None = None,
617
+ task_manager: TaskManager | None = None,
618
+ skill_loader: SkillLoader | None = None,
619
+ provider: Any = None,
620
+ tools: list[dict[str, Any]] | None = None,
621
+ permission: Any = None,
622
+ bg_manager: BackgroundManager | None = None,
623
+ subagent_system_prompt: str = "",
624
+ subagent_max_depth: int = 3,
625
+ subagent_default_type: str = "general-purpose",
626
+ team_handlers: dict[str, Callable[..., Any]] | None = None,
627
+ subagent_depth: int = 0,
628
+ mcp_manager: MCPManager | None = None,
629
+ lsp_manager: LanguageServerManager | None = None,
630
+ memory_manager: MemoryManager | None = None,
631
+ subagent_retry_policy: Any = None,
632
+ subagent_registry: Any = None,
633
+ ) -> dict[str, Callable[..., Any]]:
634
+ # Hybrid auto-diagnostics hook: built once per ``get_handlers`` call so
635
+ # edit_file / write_file share the same closure. ``None`` when LSP isn't
636
+ # wired in — handlers will then skip the snapshot/diff entirely. Importing
637
+ # the hook here (rather than in the handler modules) keeps src/core/
638
+ # free of any direct dependency on src/lsp/.
639
+ diagnostics_hook = _build_diagnostics_hook(lsp_manager)
640
+
641
+ handlers: dict[str, Callable[..., Any]] = {
642
+ "bash": partial(run_bash, cwd=workspace),
643
+ "read_file": partial(run_read, workspace=workspace),
644
+ "write_file": partial(run_write, workspace=workspace, diagnostics_hook=diagnostics_hook),
645
+ "edit_file": partial(run_edit, workspace=workspace, diagnostics_hook=diagnostics_hook),
646
+ "glob": partial(run_glob, workspace=workspace),
647
+ "grep": partial(run_grep, workspace=workspace),
648
+ "web_fetch": run_web_fetch,
649
+ "web_search": run_web_search,
650
+ }
651
+
652
+ active_todo_manager = todo_manager or TodoManager()
653
+ active_skill_loader = skill_loader or SkillLoader(resolve_skills_dir())
654
+ handlers.update(make_todo_handlers(active_todo_manager))
655
+ if task_manager is None:
656
+ handlers.update(_make_lazy_task_handlers(workspace / ".tasks.json"))
657
+ else:
658
+ handlers.update(make_task_handlers(task_manager))
659
+ handlers.update(make_skill_handlers(active_skill_loader))
660
+ handlers["background_run"] = _make_background_run_handler(
661
+ bg_manager=bg_manager,
662
+ workspace=workspace,
663
+ )
664
+
665
+ handlers.update(team_handlers or _TEAM_FALLBACK_HANDLERS)
666
+
667
+ if mcp_manager is not None:
668
+ handlers.update(build_mcp_handlers(mcp_manager))
669
+
670
+ if lsp_manager is not None:
671
+ _, lsp_handlers = build_lsp_tools(lsp_manager)
672
+ handlers.update(lsp_handlers)
673
+ else:
674
+ handlers.update(_LSP_FALLBACK_HANDLERS)
675
+
676
+ if memory_manager is not None:
677
+ handlers["memory"] = partial(run_memory, manager=memory_manager)
678
+ else:
679
+ handlers["memory"] = _memory_disabled_handler
680
+
681
+ available_tools = tools or get_tools(mcp_manager, lsp_manager)
682
+ if provider is None:
683
+ handlers["subagent"] = (
684
+ lambda task, agent_type=None, run_in_background=False, isolation="none": (
685
+ "Subagent unavailable: provider is not configured."
686
+ )
687
+ )
688
+ else:
689
+ handlers["subagent"] = (
690
+ lambda task, agent_type=None, run_in_background=False, isolation="none": run_subagent(
691
+ provider=provider,
692
+ task=task,
693
+ tools=available_tools,
694
+ handlers=handlers,
695
+ permission=permission,
696
+ system_prompt=subagent_system_prompt,
697
+ max_depth=subagent_max_depth,
698
+ current_depth=subagent_depth + 1,
699
+ agent_type=agent_type,
700
+ bg_manager=bg_manager,
701
+ run_in_background=run_in_background,
702
+ default_agent_type=subagent_default_type,
703
+ isolation=isolation,
704
+ retry_policy=subagent_retry_policy,
705
+ # Only top-level (main-loop) subagents register a resumable
706
+ # context; nested spawns pass registry=None inside run_subagent.
707
+ registry=subagent_registry,
708
+ )
709
+ )
710
+
711
+ return handlers
712
+
713
+
714
+ def rebind_workspace_handlers(
715
+ handlers: dict[str, Callable[..., Any]],
716
+ new_workspace: Path,
717
+ ) -> dict[str, Callable[..., Any]]:
718
+ """Return a shallow copy of *handlers* with file ops rooted at *new_workspace*.
719
+
720
+ Only the six workspace-bound handlers (bash/read/write/edit/glob/grep) are
721
+ replaced; every other handler (todo/task/skill/memory/mcp/lsp/subagent/
722
+ web_*/background_run) keeps its parent binding. ``bash`` rebinds its ``cwd``
723
+ keyword, the rest rebind ``workspace``. ``write_file`` / ``edit_file`` carry
724
+ a ``diagnostics_hook`` keyword on their original partial that must be
725
+ preserved across the rebind, so it is read back from ``.keywords``.
726
+ """
727
+ rebound = dict(handlers)
728
+
729
+ diag_hook = _extract_diagnostics_hook(handlers.get("write_file"))
730
+ if diag_hook is None:
731
+ diag_hook = _extract_diagnostics_hook(handlers.get("edit_file"))
732
+
733
+ rebound["bash"] = partial(run_bash, cwd=new_workspace)
734
+ rebound["read_file"] = partial(run_read, workspace=new_workspace)
735
+ rebound["write_file"] = partial(run_write, workspace=new_workspace, diagnostics_hook=diag_hook)
736
+ rebound["edit_file"] = partial(run_edit, workspace=new_workspace, diagnostics_hook=diag_hook)
737
+ rebound["glob"] = partial(run_glob, workspace=new_workspace)
738
+ rebound["grep"] = partial(run_grep, workspace=new_workspace)
739
+ return rebound
740
+
741
+
742
+ def _extract_diagnostics_hook(handler: Any) -> Any:
743
+ """Read a ``diagnostics_hook`` keyword off a partial, or ``None``."""
744
+ keywords = getattr(handler, "keywords", None)
745
+ if isinstance(keywords, dict):
746
+ return keywords.get("diagnostics_hook")
747
+ return None
748
+
749
+
750
+ def tool_search(query: str, max_results: int = 5) -> list[dict[str, Any]]:
751
+ _ = query, max_results
752
+ return []
753
+
754
+
755
+ def _make_background_run_handler(
756
+ *,
757
+ bg_manager: BackgroundManager | None,
758
+ workspace: Path,
759
+ ) -> Callable[..., str]:
760
+ bash_runner = partial(run_bash, cwd=workspace, raise_on_error=True)
761
+
762
+ def _background_run(
763
+ command: str,
764
+ timeout: int = 30,
765
+ task_id: str | None = None,
766
+ ) -> str:
767
+ if bg_manager is None:
768
+ return "Background manager unavailable."
769
+
770
+ candidate_id = task_id.strip() if isinstance(task_id, str) else ""
771
+ resolved_task_id = candidate_id or _generate_background_task_id()
772
+ bg_manager.submit(resolved_task_id, bash_runner, command, timeout)
773
+ return f"Submitted background task {resolved_task_id}"
774
+
775
+ return _background_run
776
+
777
+
778
+ def _generate_background_task_id() -> str:
779
+ return generate_random_id(8)