emdash-core 0.1.33__py3-none-any.whl → 0.1.60__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.
- emdash_core/agent/agents.py +93 -23
- emdash_core/agent/background.py +481 -0
- emdash_core/agent/hooks.py +419 -0
- emdash_core/agent/inprocess_subagent.py +114 -10
- emdash_core/agent/mcp/config.py +78 -2
- emdash_core/agent/prompts/main_agent.py +88 -1
- emdash_core/agent/prompts/plan_mode.py +65 -44
- emdash_core/agent/prompts/subagents.py +96 -8
- emdash_core/agent/prompts/workflow.py +215 -50
- emdash_core/agent/providers/models.py +1 -1
- emdash_core/agent/providers/openai_provider.py +10 -0
- emdash_core/agent/research/researcher.py +154 -45
- emdash_core/agent/runner/agent_runner.py +157 -19
- emdash_core/agent/runner/context.py +28 -9
- emdash_core/agent/runner/sdk_runner.py +29 -2
- emdash_core/agent/skills.py +81 -1
- emdash_core/agent/toolkit.py +87 -11
- emdash_core/agent/toolkits/__init__.py +117 -18
- emdash_core/agent/toolkits/base.py +87 -2
- emdash_core/agent/toolkits/explore.py +18 -0
- emdash_core/agent/toolkits/plan.py +18 -0
- emdash_core/agent/tools/__init__.py +2 -0
- emdash_core/agent/tools/coding.py +344 -52
- emdash_core/agent/tools/lsp.py +361 -0
- emdash_core/agent/tools/skill.py +21 -1
- emdash_core/agent/tools/task.py +27 -23
- emdash_core/agent/tools/task_output.py +262 -32
- emdash_core/agent/verifier/__init__.py +11 -0
- emdash_core/agent/verifier/manager.py +295 -0
- emdash_core/agent/verifier/models.py +97 -0
- emdash_core/{swarm/worktree_manager.py → agent/worktree.py} +19 -1
- emdash_core/api/agent.py +451 -5
- emdash_core/api/research.py +3 -3
- emdash_core/api/router.py +0 -4
- emdash_core/context/longevity.py +197 -0
- emdash_core/context/providers/explored_areas.py +83 -39
- emdash_core/context/reranker.py +35 -144
- emdash_core/context/simple_reranker.py +500 -0
- emdash_core/context/tool_relevance.py +84 -0
- emdash_core/core/config.py +8 -0
- emdash_core/graph/__init__.py +8 -1
- emdash_core/graph/connection.py +24 -3
- emdash_core/graph/writer.py +7 -1
- emdash_core/ingestion/repository.py +17 -198
- emdash_core/models/agent.py +14 -0
- emdash_core/server.py +1 -6
- emdash_core/sse/stream.py +16 -1
- emdash_core/utils/__init__.py +0 -2
- emdash_core/utils/git.py +103 -0
- emdash_core/utils/image.py +147 -160
- {emdash_core-0.1.33.dist-info → emdash_core-0.1.60.dist-info}/METADATA +7 -5
- {emdash_core-0.1.33.dist-info → emdash_core-0.1.60.dist-info}/RECORD +54 -58
- emdash_core/api/swarm.py +0 -223
- emdash_core/db/__init__.py +0 -67
- emdash_core/db/auth.py +0 -134
- emdash_core/db/models.py +0 -91
- emdash_core/db/provider.py +0 -222
- emdash_core/db/providers/__init__.py +0 -5
- emdash_core/db/providers/supabase.py +0 -452
- emdash_core/swarm/__init__.py +0 -17
- emdash_core/swarm/merge_agent.py +0 -383
- emdash_core/swarm/session_manager.py +0 -274
- emdash_core/swarm/swarm_runner.py +0 -226
- emdash_core/swarm/task_definition.py +0 -137
- emdash_core/swarm/worker_spawner.py +0 -319
- {emdash_core-0.1.33.dist-info → emdash_core-0.1.60.dist-info}/WHEEL +0 -0
- {emdash_core-0.1.33.dist-info → emdash_core-0.1.60.dist-info}/entry_points.txt +0 -0
|
@@ -231,94 +231,333 @@ Creates the file if it doesn't exist, or overwrites if it does."""
|
|
|
231
231
|
)
|
|
232
232
|
|
|
233
233
|
|
|
234
|
+
class EditFileTool(CodingTool):
|
|
235
|
+
"""Performs exact string replacements in files."""
|
|
236
|
+
|
|
237
|
+
name = "edit_file"
|
|
238
|
+
description = """Performs exact string replacements in files.
|
|
239
|
+
|
|
240
|
+
Usage:
|
|
241
|
+
- You must use read_file at least once before editing. This tool will error if you attempt an edit without reading the file first.
|
|
242
|
+
- When editing text from read_file output, preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix.
|
|
243
|
+
- ALWAYS prefer editing existing files. NEVER write new files unless explicitly required.
|
|
244
|
+
- The edit will FAIL if old_string is not unique in the file. Either provide more surrounding context to make it unique or use replace_all.
|
|
245
|
+
- Use replace_all for replacing/renaming strings across the file (e.g., renaming a variable)."""
|
|
246
|
+
|
|
247
|
+
def execute(
|
|
248
|
+
self,
|
|
249
|
+
file_path: str,
|
|
250
|
+
old_string: str,
|
|
251
|
+
new_string: str,
|
|
252
|
+
replace_all: bool = False,
|
|
253
|
+
) -> ToolResult:
|
|
254
|
+
"""Edit a file using search and replace.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
file_path: Absolute path to the file to modify
|
|
258
|
+
old_string: The text to replace
|
|
259
|
+
new_string: The text to replace it with (must be different from old_string)
|
|
260
|
+
replace_all: Replace all occurrences of old_string (default: false)
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
ToolResult indicating success
|
|
264
|
+
"""
|
|
265
|
+
valid, error, full_path = self._validate_path(file_path)
|
|
266
|
+
if not valid:
|
|
267
|
+
return ToolResult.error_result(error)
|
|
268
|
+
|
|
269
|
+
if not full_path.exists():
|
|
270
|
+
return ToolResult.error_result(f"File not found: {file_path}")
|
|
271
|
+
|
|
272
|
+
# Validate old_string != new_string
|
|
273
|
+
if old_string == new_string:
|
|
274
|
+
return ToolResult.error_result("old_string and new_string must be different")
|
|
275
|
+
|
|
276
|
+
try:
|
|
277
|
+
content = full_path.read_text()
|
|
278
|
+
|
|
279
|
+
# Check if old_string exists in file
|
|
280
|
+
if old_string not in content:
|
|
281
|
+
# Try to find similar content for better error message
|
|
282
|
+
lines = content.split('\n')
|
|
283
|
+
search_lines = old_string.split('\n')
|
|
284
|
+
first_search = search_lines[0].strip() if search_lines else ""
|
|
285
|
+
|
|
286
|
+
# Find lines that might be close matches
|
|
287
|
+
close_matches = []
|
|
288
|
+
for i, line in enumerate(lines):
|
|
289
|
+
if first_search and first_search in line:
|
|
290
|
+
close_matches.append(f" Line {i+1}: {line.strip()[:80]}")
|
|
291
|
+
|
|
292
|
+
error_msg = f"old_string not found in file"
|
|
293
|
+
if close_matches:
|
|
294
|
+
error_msg += f"\n\nSimilar lines found:\n" + "\n".join(close_matches[:5])
|
|
295
|
+
error_msg += "\n\nMake sure whitespace/indentation matches exactly."
|
|
296
|
+
|
|
297
|
+
return ToolResult.error_result(error_msg)
|
|
298
|
+
|
|
299
|
+
# Check for uniqueness if not replace_all
|
|
300
|
+
if not replace_all:
|
|
301
|
+
count = content.count(old_string)
|
|
302
|
+
if count > 1:
|
|
303
|
+
return ToolResult.error_result(
|
|
304
|
+
f"old_string found {count} times. Use replace_all=true or provide more context to make it unique."
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
# Perform replacement
|
|
308
|
+
if replace_all:
|
|
309
|
+
new_content = content.replace(old_string, new_string)
|
|
310
|
+
replacements = content.count(old_string)
|
|
311
|
+
else:
|
|
312
|
+
new_content = content.replace(old_string, new_string, 1)
|
|
313
|
+
replacements = 1
|
|
314
|
+
|
|
315
|
+
# Write back
|
|
316
|
+
full_path.write_text(new_content)
|
|
317
|
+
|
|
318
|
+
return ToolResult.success_result(
|
|
319
|
+
data={
|
|
320
|
+
"file_path": file_path,
|
|
321
|
+
"replacements": replacements,
|
|
322
|
+
},
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
except Exception as e:
|
|
326
|
+
return ToolResult.error_result(f"Failed to edit file: {e}")
|
|
327
|
+
|
|
328
|
+
def get_schema(self) -> dict:
|
|
329
|
+
"""Get OpenAI function schema."""
|
|
330
|
+
return self._make_schema(
|
|
331
|
+
properties={
|
|
332
|
+
"file_path": {
|
|
333
|
+
"type": "string",
|
|
334
|
+
"description": "The absolute path to the file to modify",
|
|
335
|
+
},
|
|
336
|
+
"old_string": {
|
|
337
|
+
"type": "string",
|
|
338
|
+
"description": "The text to replace",
|
|
339
|
+
},
|
|
340
|
+
"new_string": {
|
|
341
|
+
"type": "string",
|
|
342
|
+
"description": "The text to replace it with (must be different from old_string)",
|
|
343
|
+
},
|
|
344
|
+
"replace_all": {
|
|
345
|
+
"type": "boolean",
|
|
346
|
+
"description": "Replace all occurrences of old_string (default: false)",
|
|
347
|
+
"default": False,
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
required=["file_path", "old_string", "new_string"],
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
234
354
|
class ApplyDiffTool(CodingTool):
|
|
235
|
-
"""Apply a
|
|
355
|
+
"""Apply a search/replace diff to a file with fuzzy matching."""
|
|
236
356
|
|
|
237
357
|
name = "apply_diff"
|
|
238
|
-
description = """Apply a
|
|
239
|
-
|
|
358
|
+
description = """Apply changes to a file using search/replace blocks with fuzzy matching.
|
|
359
|
+
|
|
360
|
+
Format:
|
|
361
|
+
<<<<<<< SEARCH
|
|
362
|
+
[exact content to find - include enough context for uniqueness]
|
|
363
|
+
=======
|
|
364
|
+
[replacement content]
|
|
365
|
+
>>>>>>> REPLACE
|
|
366
|
+
|
|
367
|
+
You can include multiple SEARCH/REPLACE blocks in one diff.
|
|
368
|
+
The SEARCH content should match the file exactly (or very closely for fuzzy matching).
|
|
369
|
+
Include enough surrounding lines to make the match unique.
|
|
370
|
+
|
|
371
|
+
Example:
|
|
372
|
+
<<<<<<< SEARCH
|
|
373
|
+
def hello():
|
|
374
|
+
print("Hello")
|
|
375
|
+
=======
|
|
376
|
+
def hello():
|
|
377
|
+
print("Hello, World!")
|
|
378
|
+
>>>>>>> REPLACE"""
|
|
379
|
+
|
|
380
|
+
# Confidence threshold for fuzzy matching (0.0-1.0)
|
|
381
|
+
CONFIDENCE_THRESHOLD = 0.85
|
|
382
|
+
# Buffer lines to extend search area around line hints
|
|
383
|
+
BUFFER_LINES = 40
|
|
240
384
|
|
|
241
385
|
def execute(
|
|
242
386
|
self,
|
|
243
|
-
|
|
387
|
+
file_path: str,
|
|
244
388
|
diff: str,
|
|
245
389
|
) -> ToolResult:
|
|
246
|
-
"""Apply a diff to a file.
|
|
390
|
+
"""Apply a search/replace diff to a file.
|
|
247
391
|
|
|
248
392
|
Args:
|
|
249
|
-
|
|
250
|
-
diff:
|
|
393
|
+
file_path: Path to the file to modify
|
|
394
|
+
diff: Search/replace diff content
|
|
251
395
|
|
|
252
396
|
Returns:
|
|
253
397
|
ToolResult indicating success
|
|
254
398
|
"""
|
|
255
|
-
valid, error, full_path = self._validate_path(
|
|
399
|
+
valid, error, full_path = self._validate_path(file_path)
|
|
256
400
|
if not valid:
|
|
257
401
|
return ToolResult.error_result(error)
|
|
258
402
|
|
|
259
403
|
if not full_path.exists():
|
|
260
|
-
return ToolResult.error_result(f"File not found: {
|
|
404
|
+
return ToolResult.error_result(f"File not found: {file_path}")
|
|
261
405
|
|
|
262
406
|
try:
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
result = subprocess.run(
|
|
266
|
-
["patch", "-p0", "--forward", "--batch"],
|
|
267
|
-
input=diff,
|
|
268
|
-
capture_output=True,
|
|
269
|
-
text=True,
|
|
270
|
-
cwd=self.repo_root,
|
|
271
|
-
timeout=30,
|
|
272
|
-
)
|
|
407
|
+
content = full_path.read_text()
|
|
408
|
+
original_content = content
|
|
273
409
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
410
|
+
# Parse the diff into search/replace blocks
|
|
411
|
+
blocks = self._parse_diff_blocks(diff)
|
|
412
|
+
if not blocks:
|
|
413
|
+
return ToolResult.error_result(
|
|
414
|
+
"No valid SEARCH/REPLACE blocks found in diff.\n"
|
|
415
|
+
"Expected format:\n"
|
|
416
|
+
"<<<<<<< SEARCH\n"
|
|
417
|
+
"[content to find]\n"
|
|
418
|
+
"=======\n"
|
|
419
|
+
"[replacement]\n"
|
|
420
|
+
">>>>>>> REPLACE"
|
|
283
421
|
)
|
|
284
422
|
|
|
285
|
-
|
|
423
|
+
# Apply each block
|
|
424
|
+
applied = 0
|
|
425
|
+
failed = []
|
|
426
|
+
for i, block in enumerate(blocks):
|
|
427
|
+
search_text = block["search"]
|
|
428
|
+
replace_text = block["replace"]
|
|
429
|
+
|
|
430
|
+
# Try exact match first
|
|
431
|
+
if search_text in content:
|
|
432
|
+
# Check uniqueness
|
|
433
|
+
count = content.count(search_text)
|
|
434
|
+
if count > 1:
|
|
435
|
+
failed.append(f"Block {i+1}: SEARCH text found {count} times, add more context")
|
|
436
|
+
continue
|
|
437
|
+
content = content.replace(search_text, replace_text, 1)
|
|
438
|
+
applied += 1
|
|
439
|
+
else:
|
|
440
|
+
# Try fuzzy matching
|
|
441
|
+
match_result = self._fuzzy_find(content, search_text)
|
|
442
|
+
if match_result:
|
|
443
|
+
start, end, confidence = match_result
|
|
444
|
+
if confidence >= self.CONFIDENCE_THRESHOLD:
|
|
445
|
+
content = content[:start] + replace_text + content[end:]
|
|
446
|
+
applied += 1
|
|
447
|
+
else:
|
|
448
|
+
failed.append(
|
|
449
|
+
f"Block {i+1}: Best match confidence {confidence:.2f} "
|
|
450
|
+
f"below threshold {self.CONFIDENCE_THRESHOLD}"
|
|
451
|
+
)
|
|
452
|
+
else:
|
|
453
|
+
# Provide helpful error with similar lines
|
|
454
|
+
similar = self._find_similar_lines(content, search_text)
|
|
455
|
+
error_msg = f"Block {i+1}: SEARCH text not found"
|
|
456
|
+
if similar:
|
|
457
|
+
error_msg += f"\nSimilar lines:\n{similar}"
|
|
458
|
+
failed.append(error_msg)
|
|
459
|
+
|
|
460
|
+
if applied == 0:
|
|
286
461
|
return ToolResult.error_result(
|
|
287
|
-
f"
|
|
288
|
-
suggestions=["Check the diff format", "Ensure the file matches the diff context"],
|
|
462
|
+
f"Failed to apply any blocks:\n" + "\n".join(failed)
|
|
289
463
|
)
|
|
290
464
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
465
|
+
# Write back
|
|
466
|
+
full_path.write_text(content)
|
|
467
|
+
|
|
468
|
+
result_data = {
|
|
469
|
+
"file_path": file_path,
|
|
470
|
+
"blocks_applied": applied,
|
|
471
|
+
"blocks_total": len(blocks),
|
|
472
|
+
}
|
|
473
|
+
if failed:
|
|
474
|
+
result_data["warnings"] = failed
|
|
475
|
+
|
|
476
|
+
return ToolResult.success_result(data=result_data)
|
|
297
477
|
|
|
298
|
-
except FileNotFoundError:
|
|
299
|
-
return ToolResult.error_result(
|
|
300
|
-
"patch command not found",
|
|
301
|
-
suggestions=["Install patch: brew install gpatch"],
|
|
302
|
-
)
|
|
303
|
-
except subprocess.TimeoutExpired:
|
|
304
|
-
return ToolResult.error_result("Patch timed out")
|
|
305
478
|
except Exception as e:
|
|
306
479
|
return ToolResult.error_result(f"Failed to apply diff: {e}")
|
|
307
480
|
|
|
481
|
+
def _parse_diff_blocks(self, diff: str) -> list[dict]:
|
|
482
|
+
"""Parse diff into search/replace blocks."""
|
|
483
|
+
import re
|
|
484
|
+
|
|
485
|
+
blocks = []
|
|
486
|
+
# Pattern to match search/replace blocks
|
|
487
|
+
pattern = r'<<<<<<< SEARCH\n(.*?)\n=======\n(.*?)\n>>>>>>> REPLACE'
|
|
488
|
+
matches = re.findall(pattern, diff, re.DOTALL)
|
|
489
|
+
|
|
490
|
+
for search, replace in matches:
|
|
491
|
+
blocks.append({
|
|
492
|
+
"search": search,
|
|
493
|
+
"replace": replace,
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
return blocks
|
|
497
|
+
|
|
498
|
+
def _fuzzy_find(self, content: str, search_text: str) -> tuple[int, int, float] | None:
|
|
499
|
+
"""Find best fuzzy match for search_text in content.
|
|
500
|
+
|
|
501
|
+
Returns (start_index, end_index, confidence) or None if no good match.
|
|
502
|
+
"""
|
|
503
|
+
from difflib import SequenceMatcher
|
|
504
|
+
|
|
505
|
+
search_lines = search_text.split('\n')
|
|
506
|
+
content_lines = content.split('\n')
|
|
507
|
+
search_len = len(search_lines)
|
|
508
|
+
|
|
509
|
+
if search_len == 0:
|
|
510
|
+
return None
|
|
511
|
+
|
|
512
|
+
best_match = None
|
|
513
|
+
best_confidence = 0.0
|
|
514
|
+
|
|
515
|
+
# Sliding window approach
|
|
516
|
+
for i in range(len(content_lines) - search_len + 1):
|
|
517
|
+
window = '\n'.join(content_lines[i:i + search_len])
|
|
518
|
+
matcher = SequenceMatcher(None, search_text, window)
|
|
519
|
+
confidence = matcher.ratio()
|
|
520
|
+
|
|
521
|
+
if confidence > best_confidence:
|
|
522
|
+
best_confidence = confidence
|
|
523
|
+
# Calculate character positions
|
|
524
|
+
start = sum(len(line) + 1 for line in content_lines[:i])
|
|
525
|
+
end = start + len(window)
|
|
526
|
+
best_match = (start, end, confidence)
|
|
527
|
+
|
|
528
|
+
return best_match if best_confidence >= 0.5 else None
|
|
529
|
+
|
|
530
|
+
def _find_similar_lines(self, content: str, search_text: str) -> str:
|
|
531
|
+
"""Find lines in content similar to the first line of search_text."""
|
|
532
|
+
lines = content.split('\n')
|
|
533
|
+
search_first = search_text.split('\n')[0].strip() if search_text else ""
|
|
534
|
+
|
|
535
|
+
if not search_first:
|
|
536
|
+
return ""
|
|
537
|
+
|
|
538
|
+
similar = []
|
|
539
|
+
for i, line in enumerate(lines):
|
|
540
|
+
if search_first[:20] in line or line.strip()[:20] in search_first:
|
|
541
|
+
similar.append(f" Line {i+1}: {line.strip()[:60]}")
|
|
542
|
+
if len(similar) >= 3:
|
|
543
|
+
break
|
|
544
|
+
|
|
545
|
+
return "\n".join(similar)
|
|
546
|
+
|
|
308
547
|
def get_schema(self) -> dict:
|
|
309
548
|
"""Get OpenAI function schema."""
|
|
310
549
|
return self._make_schema(
|
|
311
550
|
properties={
|
|
312
|
-
"
|
|
551
|
+
"file_path": {
|
|
313
552
|
"type": "string",
|
|
314
|
-
"description": "
|
|
553
|
+
"description": "The absolute path to the file to modify",
|
|
315
554
|
},
|
|
316
555
|
"diff": {
|
|
317
556
|
"type": "string",
|
|
318
|
-
"description": "
|
|
557
|
+
"description": "Search/replace diff with <<<<<<< SEARCH ... ======= ... >>>>>>> REPLACE blocks",
|
|
319
558
|
},
|
|
320
559
|
},
|
|
321
|
-
required=["
|
|
560
|
+
required=["file_path", "diff"],
|
|
322
561
|
)
|
|
323
562
|
|
|
324
563
|
|
|
@@ -468,22 +707,37 @@ class ExecuteCommandTool(CodingTool):
|
|
|
468
707
|
|
|
469
708
|
name = "execute_command"
|
|
470
709
|
description = """Execute a shell command in the repository.
|
|
471
|
-
Commands are run from the repository root.
|
|
710
|
+
Commands are run from the repository root.
|
|
711
|
+
|
|
712
|
+
Use run_in_background=true for long-running commands (builds, servers, tests).
|
|
713
|
+
Background commands return immediately with a task_id. You'll be notified
|
|
714
|
+
when they complete, or use task_output(task_id) to check status."""
|
|
472
715
|
|
|
473
716
|
def execute(
|
|
474
717
|
self,
|
|
475
718
|
command: str,
|
|
476
719
|
timeout: int = 60,
|
|
720
|
+
run_in_background: bool = False,
|
|
721
|
+
description: str = "",
|
|
477
722
|
) -> ToolResult:
|
|
478
723
|
"""Execute a command.
|
|
479
724
|
|
|
480
725
|
Args:
|
|
481
726
|
command: Command to execute
|
|
482
|
-
timeout: Timeout in seconds
|
|
727
|
+
timeout: Timeout in seconds (ignored for background commands)
|
|
728
|
+
run_in_background: Run command in background, return immediately
|
|
729
|
+
description: Short description of what this command does
|
|
483
730
|
|
|
484
731
|
Returns:
|
|
485
|
-
ToolResult with command output
|
|
732
|
+
ToolResult with command output or task info for background
|
|
486
733
|
"""
|
|
734
|
+
if run_in_background:
|
|
735
|
+
return self._run_background(command, description)
|
|
736
|
+
else:
|
|
737
|
+
return self._run_sync(command, timeout)
|
|
738
|
+
|
|
739
|
+
def _run_sync(self, command: str, timeout: int) -> ToolResult:
|
|
740
|
+
"""Run command synchronously."""
|
|
487
741
|
try:
|
|
488
742
|
result = subprocess.run(
|
|
489
743
|
command,
|
|
@@ -506,10 +760,39 @@ Commands are run from the repository root."""
|
|
|
506
760
|
except subprocess.TimeoutExpired:
|
|
507
761
|
return ToolResult.error_result(
|
|
508
762
|
f"Command timed out after {timeout}s",
|
|
763
|
+
suggestions=["Use run_in_background=true for long-running commands"],
|
|
509
764
|
)
|
|
510
765
|
except Exception as e:
|
|
511
766
|
return ToolResult.error_result(f"Command failed: {e}")
|
|
512
767
|
|
|
768
|
+
def _run_background(self, command: str, description: str) -> ToolResult:
|
|
769
|
+
"""Run command in background."""
|
|
770
|
+
from ..background import BackgroundTaskManager
|
|
771
|
+
|
|
772
|
+
try:
|
|
773
|
+
manager = BackgroundTaskManager.get_instance()
|
|
774
|
+
task_id = manager.start_shell(
|
|
775
|
+
command=command,
|
|
776
|
+
description=description or command[:50],
|
|
777
|
+
cwd=self.repo_root,
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
return ToolResult.success_result(
|
|
781
|
+
data={
|
|
782
|
+
"task_id": task_id,
|
|
783
|
+
"status": "running",
|
|
784
|
+
"command": command,
|
|
785
|
+
"message": "Command started in background. You'll be notified when it completes.",
|
|
786
|
+
},
|
|
787
|
+
suggestions=[
|
|
788
|
+
f"Use task_output(task_id='{task_id}') to check status",
|
|
789
|
+
f"Use kill_task(task_id='{task_id}') to stop it",
|
|
790
|
+
],
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
except Exception as e:
|
|
794
|
+
return ToolResult.error_result(f"Failed to start background command: {e}")
|
|
795
|
+
|
|
513
796
|
def get_schema(self) -> dict:
|
|
514
797
|
"""Get OpenAI function schema."""
|
|
515
798
|
return self._make_schema(
|
|
@@ -520,9 +803,18 @@ Commands are run from the repository root."""
|
|
|
520
803
|
},
|
|
521
804
|
"timeout": {
|
|
522
805
|
"type": "integer",
|
|
523
|
-
"description": "Timeout in seconds",
|
|
806
|
+
"description": "Timeout in seconds (for synchronous execution)",
|
|
524
807
|
"default": 60,
|
|
525
808
|
},
|
|
809
|
+
"run_in_background": {
|
|
810
|
+
"type": "boolean",
|
|
811
|
+
"description": "Run in background and return immediately. Use for long-running commands.",
|
|
812
|
+
"default": False,
|
|
813
|
+
},
|
|
814
|
+
"description": {
|
|
815
|
+
"type": "string",
|
|
816
|
+
"description": "Short description of what this command does",
|
|
817
|
+
},
|
|
526
818
|
},
|
|
527
819
|
required=["command"],
|
|
528
820
|
)
|