executor.py 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. """Executor seam: turns a command string into an action result.
  2. Phase 3 ships `NullExecutor`; Phase 4 replaces it with the llama.cpp tool-calling
  3. executor. Executors emit their own `tool_call` event (via the supplied `emit`)
  4. before running, so a confirmation flow can exist later.
  5. """
  6. from dataclasses import dataclass, field
  7. from typing import Callable
  8. from .llm import LlmError
  9. READ_ONLY = "read_only"
  10. MUTATING = "mutating"
  11. @dataclass
  12. class ExecutionResult:
  13. """Outcome of one command execution."""
  14. ok: bool
  15. result: str
  16. tool: str | None = None
  17. args: dict = field(default_factory=dict)
  18. side_effect: str = READ_ONLY
  19. requires_confirmation: bool = False
  20. confirmed: bool | None = None
  21. class NullExecutor:
  22. """Placeholder executor used until the LLM/tool layer is wired."""
  23. def run(self, command_text: str, emit: Callable[..., None]) -> ExecutionResult:
  24. """Always declines: no action backend is configured yet."""
  25. return ExecutionResult(ok=False, result="no executor configured")
  26. class LlmExecutor:
  27. """Runs a command through the model's tool-calling loop."""
  28. def __init__(self, cfg, client, registry, slot_id: int | None = None):
  29. """Create the executor.
  30. Args:
  31. cfg: An `LlmConfig`.
  32. client: An `LlmClient`.
  33. registry: A `ToolRegistry`.
  34. slot_id: Optional llama.cpp slot id for command calls; omitted
  35. from the request when None.
  36. """
  37. self.cfg = cfg
  38. self.client = client
  39. self.registry = registry
  40. self.slot_id = slot_id
  41. def run(self, command_text: str, emit: Callable[..., None]) -> ExecutionResult:
  42. """Ask the model, validate/execute any tool calls, and return the answer."""
  43. messages: list[dict] = [
  44. {"role": "system", "content": self.cfg.system_prompt_command},
  45. {"role": "user", "content": command_text},
  46. ]
  47. tools = self.registry.specs()
  48. chat_kwargs: dict = {}
  49. if self.slot_id is not None:
  50. chat_kwargs["slot_id"] = self.slot_id
  51. last_tool: str | None = None
  52. last_args: dict = {}
  53. for _ in range(max(1, self.cfg.max_tool_iterations)):
  54. try:
  55. response = self.client.chat(messages, tools=tools, **chat_kwargs)
  56. except LlmError as e:
  57. return ExecutionResult(ok=False, result=f"llm error: {e}")
  58. if not response.tool_calls:
  59. if response.finish_reason == "length":
  60. emit("error", code="truncated", message="response truncated")
  61. return ExecutionResult(
  62. ok=False,
  63. result=response.content or "response truncated",
  64. tool=last_tool,
  65. args=last_args,
  66. )
  67. return ExecutionResult(
  68. ok=True, result=response.content, tool=last_tool, args=last_args
  69. )
  70. messages.append(
  71. {
  72. "role": "assistant",
  73. "content": response.content or "",
  74. "tool_calls": [call.message() for call in response.tool_calls],
  75. }
  76. )
  77. for call in response.tool_calls:
  78. args, error = self.registry.validate(call.name, call.arguments)
  79. tool = self.registry.get(call.name)
  80. if error is not None or tool is None:
  81. message = error or f"unknown tool: {call.name}"
  82. emit("error", code="invalid_tool_call", message=message)
  83. return ExecutionResult(ok=False, result=message, tool=call.name)
  84. emit(
  85. "tool_call",
  86. tool=call.name,
  87. args=args or {},
  88. requires_confirmation=tool.requires_confirmation,
  89. )
  90. if tool.requires_confirmation:
  91. message = f"{call.name} requires confirmation"
  92. emit("error", code="confirmation_required", message=message)
  93. return ExecutionResult(
  94. ok=False,
  95. result="confirmation required",
  96. tool=call.name,
  97. args=args,
  98. side_effect=tool.side_effect,
  99. requires_confirmation=True,
  100. )
  101. try:
  102. output = tool.run(args)
  103. except Exception as e: # noqa: BLE001 - a tool crash must not kill the loop
  104. emit("error", code="tool_error", message=str(e))
  105. return ExecutionResult(
  106. ok=False,
  107. result=f"tool error: {e}",
  108. tool=call.name,
  109. args=args,
  110. side_effect=tool.side_effect,
  111. )
  112. messages.append(
  113. {"role": "tool", "tool_call_id": call.id, "content": output}
  114. )
  115. last_tool, last_args = call.name, args
  116. emit("error", code="tool_loop", message="tool loop limit exceeded")
  117. return ExecutionResult(
  118. ok=False, result="tool loop limit exceeded", tool=last_tool, args=last_args
  119. )