runtime.py 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. from __future__ import annotations
  2. from dataclasses import dataclass
  3. from .commands import PORTED_COMMANDS
  4. from .context import PortContext, build_port_context, render_context
  5. from .history import HistoryLog
  6. from .models import PermissionDenial, PortingModule
  7. from .query_engine import QueryEngineConfig, QueryEnginePort, TurnResult
  8. from .setup import SetupReport, WorkspaceSetup, run_setup
  9. from .system_init import build_system_init_message
  10. from .tools import PORTED_TOOLS
  11. from .execution_registry import build_execution_registry
  12. @dataclass(frozen=True)
  13. class RoutedMatch:
  14. kind: str
  15. name: str
  16. source_hint: str
  17. score: int
  18. @dataclass
  19. class RuntimeSession:
  20. prompt: str
  21. context: PortContext
  22. setup: WorkspaceSetup
  23. setup_report: SetupReport
  24. system_init_message: str
  25. history: HistoryLog
  26. routed_matches: list[RoutedMatch]
  27. turn_result: TurnResult
  28. command_execution_messages: tuple[str, ...]
  29. tool_execution_messages: tuple[str, ...]
  30. stream_events: tuple[dict[str, object], ...]
  31. persisted_session_path: str
  32. def as_markdown(self) -> str:
  33. lines = [
  34. '# Runtime Session',
  35. '',
  36. f'Prompt: {self.prompt}',
  37. '',
  38. '## Context',
  39. render_context(self.context),
  40. '',
  41. '## Setup',
  42. f'- Python: {self.setup.python_version} ({self.setup.implementation})',
  43. f'- Platform: {self.setup.platform_name}',
  44. f'- Test command: {self.setup.test_command}',
  45. '',
  46. '## Startup Steps',
  47. *(f'- {step}' for step in self.setup.startup_steps()),
  48. '',
  49. '## System Init',
  50. self.system_init_message,
  51. '',
  52. '## Routed Matches',
  53. ]
  54. if self.routed_matches:
  55. lines.extend(
  56. f'- [{match.kind}] {match.name} ({match.score}) — {match.source_hint}'
  57. for match in self.routed_matches
  58. )
  59. else:
  60. lines.append('- none')
  61. lines.extend([
  62. '',
  63. '## Command Execution',
  64. *(self.command_execution_messages or ('none',)),
  65. '',
  66. '## Tool Execution',
  67. *(self.tool_execution_messages or ('none',)),
  68. '',
  69. '## Stream Events',
  70. *(f"- {event['type']}: {event}" for event in self.stream_events),
  71. '',
  72. '## Turn Result',
  73. self.turn_result.output,
  74. '',
  75. f'Persisted session path: {self.persisted_session_path}',
  76. '',
  77. self.history.as_markdown(),
  78. ])
  79. return '\n'.join(lines)
  80. class PortRuntime:
  81. def route_prompt(self, prompt: str, limit: int = 5) -> list[RoutedMatch]:
  82. tokens = {token.lower() for token in prompt.replace('/', ' ').replace('-', ' ').split() if token}
  83. by_kind = {
  84. 'command': self._collect_matches(tokens, PORTED_COMMANDS, 'command'),
  85. 'tool': self._collect_matches(tokens, PORTED_TOOLS, 'tool'),
  86. }
  87. selected: list[RoutedMatch] = []
  88. for kind in ('command', 'tool'):
  89. if by_kind[kind]:
  90. selected.append(by_kind[kind].pop(0))
  91. leftovers = sorted(
  92. [match for matches in by_kind.values() for match in matches],
  93. key=lambda item: (-item.score, item.kind, item.name),
  94. )
  95. selected.extend(leftovers[: max(0, limit - len(selected))])
  96. return selected[:limit]
  97. def bootstrap_session(self, prompt: str, limit: int = 5) -> RuntimeSession:
  98. context = build_port_context()
  99. setup_report = run_setup(trusted=True)
  100. setup = setup_report.setup
  101. history = HistoryLog()
  102. engine = QueryEnginePort.from_workspace()
  103. history.add('context', f'python_files={context.python_file_count}, archive_available={context.archive_available}')
  104. history.add('registry', f'commands={len(PORTED_COMMANDS)}, tools={len(PORTED_TOOLS)}')
  105. matches = self.route_prompt(prompt, limit=limit)
  106. registry = build_execution_registry()
  107. command_execs = tuple(registry.command(match.name).execute(prompt) for match in matches if match.kind == 'command' and registry.command(match.name))
  108. tool_execs = tuple(registry.tool(match.name).execute(prompt) for match in matches if match.kind == 'tool' and registry.tool(match.name))
  109. denials = tuple(self._infer_permission_denials(matches))
  110. stream_events = tuple(engine.stream_submit_message(
  111. prompt,
  112. matched_commands=tuple(match.name for match in matches if match.kind == 'command'),
  113. matched_tools=tuple(match.name for match in matches if match.kind == 'tool'),
  114. denied_tools=denials,
  115. ))
  116. turn_result = engine.submit_message(
  117. prompt,
  118. matched_commands=tuple(match.name for match in matches if match.kind == 'command'),
  119. matched_tools=tuple(match.name for match in matches if match.kind == 'tool'),
  120. denied_tools=denials,
  121. )
  122. persisted_session_path = engine.persist_session()
  123. history.add('routing', f'matches={len(matches)} for prompt={prompt!r}')
  124. history.add('execution', f'command_execs={len(command_execs)} tool_execs={len(tool_execs)}')
  125. history.add('turn', f'commands={len(turn_result.matched_commands)} tools={len(turn_result.matched_tools)} denials={len(turn_result.permission_denials)} stop={turn_result.stop_reason}')
  126. history.add('session_store', persisted_session_path)
  127. return RuntimeSession(
  128. prompt=prompt,
  129. context=context,
  130. setup=setup,
  131. setup_report=setup_report,
  132. system_init_message=build_system_init_message(trusted=True),
  133. history=history,
  134. routed_matches=matches,
  135. turn_result=turn_result,
  136. command_execution_messages=command_execs,
  137. tool_execution_messages=tool_execs,
  138. stream_events=stream_events,
  139. persisted_session_path=persisted_session_path,
  140. )
  141. def run_turn_loop(self, prompt: str, limit: int = 5, max_turns: int = 3, structured_output: bool = False) -> list[TurnResult]:
  142. engine = QueryEnginePort.from_workspace()
  143. engine.config = QueryEngineConfig(max_turns=max_turns, structured_output=structured_output)
  144. matches = self.route_prompt(prompt, limit=limit)
  145. command_names = tuple(match.name for match in matches if match.kind == 'command')
  146. tool_names = tuple(match.name for match in matches if match.kind == 'tool')
  147. results: list[TurnResult] = []
  148. for turn in range(max_turns):
  149. turn_prompt = prompt if turn == 0 else f'{prompt} [turn {turn + 1}]'
  150. result = engine.submit_message(turn_prompt, command_names, tool_names, ())
  151. results.append(result)
  152. if result.stop_reason != 'completed':
  153. break
  154. return results
  155. def _infer_permission_denials(self, matches: list[RoutedMatch]) -> list[PermissionDenial]:
  156. denials: list[PermissionDenial] = []
  157. for match in matches:
  158. if match.kind == 'tool' and 'bash' in match.name.lower():
  159. denials.append(PermissionDenial(tool_name=match.name, reason='destructive shell execution remains gated in the Python port'))
  160. return denials
  161. def _collect_matches(self, tokens: set[str], modules: tuple[PortingModule, ...], kind: str) -> list[RoutedMatch]:
  162. matches: list[RoutedMatch] = []
  163. for module in modules:
  164. score = self._score(tokens, module)
  165. if score > 0:
  166. matches.append(RoutedMatch(kind=kind, name=module.name, source_hint=module.source_hint, score=score))
  167. matches.sort(key=lambda item: (-item.score, item.name))
  168. return matches
  169. @staticmethod
  170. def _score(tokens: set[str], module: PortingModule) -> int:
  171. haystacks = [module.name.lower(), module.source_hint.lower(), module.responsibility.lower()]
  172. score = 0
  173. for token in tokens:
  174. if any(token in haystack for haystack in haystacks):
  175. score += 1
  176. return score