abstractagent 0.2.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.
abstractagent/repl.py ADDED
@@ -0,0 +1,457 @@
1
+ """Async REPL for the ReAct agent.
2
+
3
+ Provides an interactive interface with:
4
+ - Real-time step visibility
5
+ - Pause/resume capability
6
+ - Interactive question handling
7
+ - Run persistence for resume across sessions
8
+ """
9
+
10
+ import asyncio
11
+ import sys
12
+ import argparse
13
+ import json
14
+ from typing import Dict, Any, Optional
15
+ from datetime import datetime
16
+ from pathlib import Path
17
+
18
+ from abstractcore.tools import ToolDefinition
19
+ from abstractruntime import (
20
+ RunStatus,
21
+ InMemoryRunStore,
22
+ InMemoryLedgerStore,
23
+ JsonFileRunStore,
24
+ JsonlLedgerStore,
25
+ )
26
+ from abstractruntime.integrations.abstractcore import MappingToolExecutor, create_local_runtime
27
+
28
+ from .agents.react import ReactAgent
29
+ from .tools import ALL_TOOLS
30
+ from .ui.question import get_user_response_async, Colors, _c
31
+
32
+
33
+ class AgentREPL:
34
+ """Interactive REPL for the ReAct agent."""
35
+
36
+ def __init__(self, provider: str, model: str, state_file: Optional[str] = None):
37
+ self.provider = provider
38
+ self.model = model
39
+ self.state_file = state_file
40
+ self.agent: Optional[ReactAgent] = None
41
+ self._interrupted = False
42
+ self._tools = list(ALL_TOOLS)
43
+
44
+ # Setup runtime with persistence. If a state file is provided, use
45
+ # file-backed stores so runs can resume across process restarts.
46
+ if self.state_file:
47
+ base_dir = Path(self.state_file).expanduser().resolve()
48
+ store_dir = base_dir.with_name(base_dir.name + ".d")
49
+ self.run_store = JsonFileRunStore(store_dir)
50
+ self.ledger_store = JsonlLedgerStore(store_dir)
51
+ else:
52
+ self.run_store = InMemoryRunStore()
53
+ self.ledger_store = InMemoryLedgerStore()
54
+
55
+ self.runtime = create_local_runtime(
56
+ provider=provider,
57
+ model=model,
58
+ run_store=self.run_store,
59
+ ledger_store=self.ledger_store,
60
+ tool_executor=MappingToolExecutor.from_tools(self._tools),
61
+ )
62
+
63
+ self.agent = ReactAgent(
64
+ runtime=self.runtime,
65
+ tools=self._tools,
66
+ on_step=self.print_step,
67
+ )
68
+
69
+ def print_step(self, step: str, data: Dict[str, Any]) -> None:
70
+ """Print a step to the console with formatting."""
71
+ timestamp = datetime.now().strftime("%H:%M:%S")
72
+ ts = _c(f"[{timestamp}]", Colors.DIM)
73
+
74
+ if step == "init":
75
+ task = data.get('task', '')[:60]
76
+ print(f"\n{ts} {_c('Starting:', Colors.CYAN, Colors.BOLD)} {task}")
77
+ elif step == "reason":
78
+ iteration = data.get('iteration', '?')
79
+ print(f"{ts} {_c(f'Thinking (step {iteration})...', Colors.YELLOW)}")
80
+ elif step == "parse":
81
+ has_tools = data.get('has_tool_calls', False)
82
+ if has_tools:
83
+ print(f"{ts} {_c('Decided to use tools', Colors.BLUE)}")
84
+ elif step == "act":
85
+ tool = data.get('tool', 'unknown')
86
+ args = data.get('args', {})
87
+ args_str = json.dumps(args) if args else ""
88
+ if len(args_str) > 50:
89
+ args_str = args_str[:47] + "..."
90
+ print(f"{ts} {_c('Tool:', Colors.GREEN)} {tool}({args_str})")
91
+ elif step == "observe":
92
+ result = data.get('result', '')[:80]
93
+ print(f"{ts} {_c('Result:', Colors.DIM)} {result}")
94
+ elif step == "ask_user":
95
+ print(f"{ts} {_c('Agent has a question...', Colors.MAGENTA, Colors.BOLD)}")
96
+ elif step == "user_response":
97
+ response = data.get('response', '')[:50]
98
+ print(f"{ts} {_c('You answered:', Colors.MAGENTA)} {response}")
99
+ elif step == "done":
100
+ answer = data.get('answer', '')
101
+ print(f"\n{ts} {_c('ANSWER:', Colors.GREEN, Colors.BOLD)}")
102
+ print(_c("─" * 60, Colors.DIM))
103
+ print(answer)
104
+ print(_c("─" * 60, Colors.DIM))
105
+ elif step == "max_iterations":
106
+ print(f"{ts} {_c('Max iterations reached', Colors.YELLOW)}")
107
+
108
+ async def handle_waiting_state(self) -> bool:
109
+ """Handle agent waiting state (questions).
110
+
111
+ Returns True if handled and should continue, False to stop.
112
+ """
113
+ if not self.agent or not self.agent.is_waiting():
114
+ return False
115
+
116
+ question = self.agent.get_pending_question()
117
+ if not question:
118
+ return False
119
+
120
+ # Get user response via UI
121
+ response = await get_user_response_async(
122
+ prompt=question.get("prompt", "Please respond:"),
123
+ choices=question.get("choices"),
124
+ allow_free_text=question.get("allow_free_text", True),
125
+ )
126
+
127
+ if not response:
128
+ print(_c("No response provided. Agent paused.", Colors.YELLOW))
129
+ return False
130
+
131
+ # Resume agent with response
132
+ self.agent.resume(response)
133
+ return True
134
+
135
+ async def run_agent_async(self, task: str) -> None:
136
+ """Run the agent asynchronously with step visibility."""
137
+ self.agent.start(task)
138
+ if self.state_file:
139
+ self.agent.save_state(self.state_file)
140
+ self._interrupted = False
141
+
142
+ print(f"\n{_c('═' * 60, Colors.CYAN)}")
143
+ print(f"{_c('Task:', Colors.BOLD)} {task}")
144
+ print(f"{_c('═' * 60, Colors.CYAN)}")
145
+
146
+ try:
147
+ while not self._interrupted:
148
+ state = self.agent.step()
149
+
150
+ if state.status == RunStatus.COMPLETED:
151
+ print(f"\n{_c('═' * 60, Colors.GREEN)}")
152
+ print(f"{_c('Completed', Colors.GREEN, Colors.BOLD)} in {state.output.get('iterations', '?')} steps")
153
+ print(f"{_c('═' * 60, Colors.GREEN)}")
154
+ if self.state_file:
155
+ self.agent.clear_state(self.state_file)
156
+ break
157
+
158
+ elif state.status == RunStatus.WAITING:
159
+ # Handle question
160
+ handled = await self.handle_waiting_state()
161
+ if not handled:
162
+ print(f"\n{_c('Agent paused.', Colors.YELLOW)} Type 'resume' to continue.")
163
+ break
164
+ # After handling, continue the loop to process next step
165
+ continue
166
+
167
+ elif state.status == RunStatus.FAILED:
168
+ print(f"\n{_c('Failed:', Colors.YELLOW)} {state.error}")
169
+ if self.state_file:
170
+ self.agent.clear_state(self.state_file)
171
+ break
172
+
173
+ # Small delay for interrupt handling
174
+ await asyncio.sleep(0.01)
175
+
176
+ except asyncio.CancelledError:
177
+ print(f"\n{_c('Interrupted', Colors.YELLOW)}")
178
+ self._interrupted = True
179
+
180
+ def interrupt(self) -> None:
181
+ """Interrupt the running agent."""
182
+ self._interrupted = True
183
+ print(f"\n{_c('Interrupting...', Colors.YELLOW)} (state preserved)")
184
+
185
+ async def resume_agent(self) -> None:
186
+ """Resume a paused agent."""
187
+ if not self.agent:
188
+ print("No agent to resume. Start a new task.")
189
+ return
190
+
191
+ state = self.agent.get_state()
192
+ if not state:
193
+ print("No active run.")
194
+ return
195
+
196
+ if state.status == RunStatus.WAITING:
197
+ handled = await self.handle_waiting_state()
198
+ if handled:
199
+ # Continue running after handling
200
+ await self._continue_running()
201
+ elif state.status == RunStatus.RUNNING:
202
+ self._interrupted = False
203
+ await self._continue_running()
204
+ else:
205
+ print(f"Agent is {state.status.value}, cannot resume.")
206
+
207
+ async def _continue_running(self) -> None:
208
+ """Continue running the agent after resume."""
209
+ try:
210
+ while not self._interrupted:
211
+ state = self.agent.step()
212
+
213
+ if state.status == RunStatus.COMPLETED:
214
+ print(f"\n{_c('═' * 60, Colors.GREEN)}")
215
+ print(f"{_c('Completed', Colors.GREEN, Colors.BOLD)} in {state.output.get('iterations', '?')} steps")
216
+ print(f"{_c('═' * 60, Colors.GREEN)}")
217
+ break
218
+ elif state.status == RunStatus.WAITING:
219
+ handled = await self.handle_waiting_state()
220
+ if not handled:
221
+ print(f"\n{_c('Agent paused.', Colors.YELLOW)} Type 'resume' to continue.")
222
+ break
223
+ continue
224
+ elif state.status == RunStatus.FAILED:
225
+ print(f"\n{_c('Failed:', Colors.YELLOW)} {state.error}")
226
+ break
227
+
228
+ await asyncio.sleep(0.01)
229
+ except asyncio.CancelledError:
230
+ self._interrupted = True
231
+
232
+ def show_status(self) -> None:
233
+ """Show current agent status."""
234
+ if not self.agent:
235
+ print("No active agent")
236
+ return
237
+
238
+ state = self.agent.get_state()
239
+ if not state:
240
+ print("No active run")
241
+ return
242
+
243
+ print(f"\n{_c('Agent Status', Colors.CYAN, Colors.BOLD)}")
244
+ print(_c("─" * 40, Colors.DIM))
245
+ print(f" Run ID: {state.run_id[:16]}...")
246
+ print(f" Status: {_c(state.status.value, Colors.GREEN if state.status == RunStatus.COMPLETED else Colors.YELLOW)}")
247
+ print(f" Node: {state.current_node}")
248
+ print(f" Iteration: {state.vars.get('iteration', 0)}")
249
+
250
+ if state.status == RunStatus.WAITING and state.waiting:
251
+ print(f"\n {_c('Waiting for:', Colors.MAGENTA)} {state.waiting.reason.value}")
252
+ if state.waiting.prompt:
253
+ print(f" {_c('Question:', Colors.MAGENTA)} {state.waiting.prompt[:50]}...")
254
+
255
+ print(_c("─" * 40, Colors.DIM))
256
+
257
+ def show_history(self) -> None:
258
+ """Show agent conversation history."""
259
+ if not self.agent:
260
+ print("No active agent")
261
+ return
262
+
263
+ state = self.agent.get_state()
264
+ if not state:
265
+ print("No active run")
266
+ return
267
+
268
+ history = state.vars.get('messages', [])
269
+ if not history:
270
+ print("No history yet")
271
+ return
272
+
273
+ print(f"\n{_c('Conversation History', Colors.CYAN, Colors.BOLD)}")
274
+ print(_c("─" * 60, Colors.DIM))
275
+
276
+ for i, entry in enumerate(history):
277
+ role = entry.get('role', 'unknown')
278
+ content = entry.get('content', '')
279
+
280
+ if role == "assistant":
281
+ role_color = Colors.GREEN
282
+ elif role == "tool":
283
+ role_color = Colors.BLUE
284
+ elif role == "user":
285
+ role_color = Colors.MAGENTA
286
+ else:
287
+ role_color = Colors.DIM
288
+
289
+ # Truncate long content
290
+ if len(content) > 100:
291
+ content = content[:97] + "..."
292
+
293
+ print(f"[{i+1}] {_c(role, role_color, Colors.BOLD)}: {content}")
294
+
295
+ print(_c("─" * 60, Colors.DIM))
296
+
297
+ def show_help(self) -> None:
298
+ """Show help message."""
299
+ print(f"""
300
+ {_c('ReAct Agent REPL', Colors.CYAN, Colors.BOLD)}
301
+ {_c('─' * 40, Colors.DIM)}
302
+
303
+ {_c('Commands:', Colors.BOLD)}
304
+ <task> Start agent with a task
305
+ resume Resume a paused/interrupted agent
306
+ status Show current agent status
307
+ history Show conversation history
308
+ tools List available tools
309
+ help Show this help
310
+ quit Exit
311
+
312
+ {_c('During execution:', Colors.BOLD)}
313
+ Ctrl+C Interrupt (preserves state)
314
+
315
+ {_c('Examples:', Colors.DIM)}
316
+ > list the python files in this directory
317
+ > what is in the README.md file?
318
+ > search for TODO comments in the code
319
+ """)
320
+
321
+ def show_tools(self) -> None:
322
+ """Show available tools."""
323
+ print(f"\n{_c('Available Tools', Colors.CYAN, Colors.BOLD)}")
324
+ print(_c("─" * 50, Colors.DIM))
325
+
326
+ for tool in self._tools:
327
+ tool_def = getattr(tool, "_tool_definition", None)
328
+ if tool_def is None:
329
+ tool_def = ToolDefinition.from_function(tool)
330
+
331
+ params = ", ".join(str(k) for k in (tool_def.parameters or {}).keys())
332
+ print(f" {_c(tool_def.name, Colors.GREEN)}({params})")
333
+ print(f" {_c(tool_def.description, Colors.DIM)}")
334
+
335
+ # Show built-in ask_user
336
+ print(f" {_c('ask_user', Colors.MAGENTA)}(question, choices?)")
337
+ print(f" {_c('Ask the user a question', Colors.DIM)}")
338
+
339
+ print(_c("─" * 50, Colors.DIM))
340
+
341
+ async def repl_loop(self) -> None:
342
+ """Main REPL loop."""
343
+ # Header
344
+ print(f"""
345
+ {_c('╔' + '═' * 58 + '╗', Colors.CYAN)}
346
+ {_c('║', Colors.CYAN)} {_c('ReAct Agent REPL', Colors.BOLD)} {_c('║', Colors.CYAN)}
347
+ {_c('║', Colors.CYAN)} {_c('║', Colors.CYAN)}
348
+ {_c('║', Colors.CYAN)} Provider: {self.provider:<15} Model: {self.model:<17} {_c('║', Colors.CYAN)}
349
+ {_c('║', Colors.CYAN)} {_c('║', Colors.CYAN)}
350
+ {_c('║', Colors.CYAN)} Type {_c("'help'", Colors.GREEN)} for commands, or enter a task. {_c('║', Colors.CYAN)}
351
+ {_c('╚' + '═' * 58 + '╝', Colors.CYAN)}
352
+ """)
353
+
354
+ self.show_tools()
355
+
356
+ if self.state_file:
357
+ try:
358
+ loaded = self.agent.load_state(self.state_file)
359
+ if loaded is not None:
360
+ print(f"\n{_c('Loaded saved run.', Colors.CYAN)} Type 'status' or 'resume'.")
361
+ except Exception as e:
362
+ print(f"{_c('State load failed:', Colors.YELLOW)} {e}")
363
+
364
+ agent_task: Optional[asyncio.Task] = None
365
+
366
+ while True:
367
+ try:
368
+ # Get input
369
+ if sys.stdin.isatty():
370
+ prompt = f"\n{_c('>', Colors.CYAN, Colors.BOLD)} "
371
+ user_input = await asyncio.get_event_loop().run_in_executor(
372
+ None, lambda: input(prompt).strip()
373
+ )
374
+ else:
375
+ line = sys.stdin.readline()
376
+ if not line:
377
+ break
378
+ user_input = line.strip()
379
+
380
+ if not user_input:
381
+ continue
382
+
383
+ # Handle commands
384
+ cmd = user_input.lower()
385
+
386
+ if cmd in ('quit', 'exit', 'q'):
387
+ if agent_task and not agent_task.done():
388
+ agent_task.cancel()
389
+ try:
390
+ await agent_task
391
+ except asyncio.CancelledError:
392
+ pass
393
+ print(_c("Goodbye!", Colors.CYAN))
394
+ break
395
+
396
+ elif cmd == 'help':
397
+ self.show_help()
398
+
399
+ elif cmd == 'tools':
400
+ self.show_tools()
401
+
402
+ elif cmd == 'status':
403
+ self.show_status()
404
+
405
+ elif cmd == 'history':
406
+ self.show_history()
407
+
408
+ elif cmd == 'resume':
409
+ await self.resume_agent()
410
+
411
+ else:
412
+ # Treat as a task
413
+ if agent_task and not agent_task.done():
414
+ print(_c("Agent is running. Use Ctrl+C to interrupt.", Colors.YELLOW))
415
+ continue
416
+
417
+ agent_task = asyncio.create_task(self.run_agent_async(user_input))
418
+ try:
419
+ await agent_task
420
+ except asyncio.CancelledError:
421
+ pass
422
+
423
+ except KeyboardInterrupt:
424
+ print()
425
+ if agent_task and not agent_task.done():
426
+ self.interrupt()
427
+ agent_task.cancel()
428
+ try:
429
+ await agent_task
430
+ except asyncio.CancelledError:
431
+ pass
432
+ else:
433
+ print(_c("Type 'quit' to exit.", Colors.DIM))
434
+ except EOFError:
435
+ break
436
+ except Exception as e:
437
+ print(f"{_c('Error:', Colors.YELLOW)} {e}")
438
+
439
+
440
+ def main():
441
+ """Entry point for the REPL."""
442
+ parser = argparse.ArgumentParser(description="ReAct Agent REPL")
443
+ parser.add_argument("--provider", default="ollama", help="LLM provider")
444
+ parser.add_argument("--model", default="qwen3:1.7b-q4_K_M", help="Model name")
445
+ parser.add_argument("--state-file", help="File to persist agent state")
446
+ args = parser.parse_args()
447
+
448
+ repl = AgentREPL(
449
+ provider=args.provider,
450
+ model=args.model,
451
+ state_file=args.state_file,
452
+ )
453
+ asyncio.run(repl.repl_loop())
454
+
455
+
456
+ if __name__ == "__main__":
457
+ main()
@@ -0,0 +1,7 @@
1
+ """Sandbox implementations used by agents."""
2
+
3
+ from .interface import ExecutionResult, Sandbox
4
+ from .local import LocalSandbox
5
+
6
+ __all__ = ["ExecutionResult", "Sandbox", "LocalSandbox"]
7
+
@@ -0,0 +1,22 @@
1
+ """Sandbox interfaces for CodeAct-style agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Optional, Protocol
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class ExecutionResult:
11
+ stdout: str
12
+ stderr: str
13
+ exit_code: int
14
+ duration_ms: float
15
+ error: Optional[str] = None
16
+
17
+
18
+ class Sandbox(Protocol):
19
+ def execute(self, code: str, *, timeout_s: float = 10.0) -> ExecutionResult: ...
20
+
21
+ def reset(self) -> None: ...
22
+
@@ -0,0 +1,68 @@
1
+ """Local subprocess sandbox (development-only).
2
+
3
+ This is intentionally minimal: it enforces a timeout and captures stdout/stderr.
4
+ Stronger isolation (docker/e2b/wasm) can be added later.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import subprocess
11
+ import sys
12
+ import time
13
+ from typing import Optional
14
+
15
+ from .interface import ExecutionResult
16
+
17
+
18
+ class LocalSandbox:
19
+ def __init__(
20
+ self,
21
+ *,
22
+ cwd: Optional[str] = None,
23
+ python_executable: Optional[str] = None,
24
+ ):
25
+ self._cwd = cwd or os.getcwd()
26
+ self._python = python_executable or sys.executable
27
+
28
+ def reset(self) -> None:
29
+ # Stateless sandbox (new subprocess per call).
30
+ return None
31
+
32
+ def execute(self, code: str, *, timeout_s: float = 10.0) -> ExecutionResult:
33
+ started = time.monotonic()
34
+ try:
35
+ completed = subprocess.run(
36
+ [self._python, "-c", code],
37
+ cwd=self._cwd,
38
+ capture_output=True,
39
+ text=True,
40
+ timeout=float(timeout_s),
41
+ )
42
+ duration_ms = (time.monotonic() - started) * 1000.0
43
+ return ExecutionResult(
44
+ stdout=completed.stdout or "",
45
+ stderr=completed.stderr or "",
46
+ exit_code=int(completed.returncode),
47
+ duration_ms=duration_ms,
48
+ error=None,
49
+ )
50
+ except subprocess.TimeoutExpired as e:
51
+ duration_ms = (time.monotonic() - started) * 1000.0
52
+ return ExecutionResult(
53
+ stdout=(e.stdout or "") if isinstance(e.stdout, str) else "",
54
+ stderr=(e.stderr or "") if isinstance(e.stderr, str) else "",
55
+ exit_code=124,
56
+ duration_ms=duration_ms,
57
+ error=f"Timeout after {timeout_s}s",
58
+ )
59
+ except Exception as e:
60
+ duration_ms = (time.monotonic() - started) * 1000.0
61
+ return ExecutionResult(
62
+ stdout="",
63
+ stderr="",
64
+ exit_code=1,
65
+ duration_ms=duration_ms,
66
+ error=str(e),
67
+ )
68
+
@@ -0,0 +1,58 @@
1
+ """AbstractAgent tools.
2
+
3
+ Common tools are imported from AbstractCore (canonical source).
4
+ Agent-specific tools (execute_python, self_improve) are defined locally.
5
+ """
6
+
7
+ # Import common tools from AbstractCore (canonical source)
8
+ from abstractcore.tools.common_tools import (
9
+ list_files,
10
+ read_file,
11
+ search_files,
12
+ write_file,
13
+ edit_file,
14
+ web_search,
15
+ fetch_url,
16
+ execute_command,
17
+ )
18
+
19
+ # Agent-specific tools
20
+ from .code_execution import execute_python
21
+ from .self_improve import self_improve
22
+
23
+ # Default toolset for agents
24
+ ALL_TOOLS = [
25
+ # File operations (from abstractcore)
26
+ list_files,
27
+ read_file,
28
+ search_files,
29
+ write_file,
30
+ edit_file,
31
+ # Web tools (from abstractcore)
32
+ web_search,
33
+ fetch_url,
34
+ # System tools (from abstractcore)
35
+ execute_command,
36
+ # Agent-specific tools
37
+ execute_python,
38
+ self_improve,
39
+ ]
40
+
41
+ __all__ = [
42
+ # File operations
43
+ "list_files",
44
+ "read_file",
45
+ "search_files",
46
+ "write_file",
47
+ "edit_file",
48
+ # Web tools
49
+ "web_search",
50
+ "fetch_url",
51
+ # System tools
52
+ "execute_command",
53
+ # Agent-specific tools
54
+ "execute_python",
55
+ "self_improve",
56
+ # Collections
57
+ "ALL_TOOLS",
58
+ ]
@@ -0,0 +1,45 @@
1
+ """Code execution tool used by CodeAct agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abstractcore.tools import tool
6
+
7
+
8
+ def _truncate(text: str, *, limit: int = 6000) -> str:
9
+ if not text:
10
+ return ""
11
+ if len(text) <= limit:
12
+ return text
13
+ head = text[:4000]
14
+ tail = text[-1500:] if len(text) > 5500 else ""
15
+ return head + f"\n... (truncated, {len(text)} chars total)\n" + tail
16
+
17
+
18
+ @tool(
19
+ name="execute_python",
20
+ description="Execute a Python snippet in a subprocess sandbox (timeout enforced). Returns stdout/stderr/exit_code.",
21
+ when_to_use="When you need to compute, inspect files, or transform data using Python code",
22
+ )
23
+ def execute_python(code: str, timeout_s: float = 10.0) -> dict:
24
+ """Execute Python code in a local subprocess.
25
+
26
+ Notes:
27
+ - This is a dev sandbox (timeout only). It is not a hardened security boundary.
28
+ - Use small snippets and print what you need.
29
+ """
30
+ code = str(code or "")
31
+ if not code.strip():
32
+ raise ValueError("code must be a non-empty string")
33
+
34
+ from ..sandbox.local import LocalSandbox
35
+
36
+ sandbox = LocalSandbox()
37
+ result = sandbox.execute(code, timeout_s=float(timeout_s))
38
+ return {
39
+ "stdout": _truncate(result.stdout),
40
+ "stderr": _truncate(result.stderr),
41
+ "exit_code": int(result.exit_code),
42
+ "duration_ms": float(result.duration_ms),
43
+ "error": result.error,
44
+ }
45
+