ctrlcode 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 (75) hide show
  1. ctrlcode/__init__.py +8 -0
  2. ctrlcode/agents/__init__.py +29 -0
  3. ctrlcode/agents/cleanup.py +388 -0
  4. ctrlcode/agents/communication.py +439 -0
  5. ctrlcode/agents/observability.py +421 -0
  6. ctrlcode/agents/react_loop.py +297 -0
  7. ctrlcode/agents/registry.py +211 -0
  8. ctrlcode/agents/result_parser.py +242 -0
  9. ctrlcode/agents/workflow.py +723 -0
  10. ctrlcode/analysis/__init__.py +28 -0
  11. ctrlcode/analysis/ast_diff.py +163 -0
  12. ctrlcode/analysis/bug_detector.py +149 -0
  13. ctrlcode/analysis/code_graphs.py +329 -0
  14. ctrlcode/analysis/semantic.py +205 -0
  15. ctrlcode/analysis/static.py +183 -0
  16. ctrlcode/analysis/synthesizer.py +281 -0
  17. ctrlcode/analysis/tests.py +189 -0
  18. ctrlcode/cleanup/__init__.py +16 -0
  19. ctrlcode/cleanup/auto_merge.py +350 -0
  20. ctrlcode/cleanup/doc_gardening.py +388 -0
  21. ctrlcode/cleanup/pr_automation.py +330 -0
  22. ctrlcode/cleanup/scheduler.py +356 -0
  23. ctrlcode/config.py +380 -0
  24. ctrlcode/embeddings/__init__.py +6 -0
  25. ctrlcode/embeddings/embedder.py +192 -0
  26. ctrlcode/embeddings/vector_store.py +213 -0
  27. ctrlcode/fuzzing/__init__.py +24 -0
  28. ctrlcode/fuzzing/analyzer.py +280 -0
  29. ctrlcode/fuzzing/budget.py +112 -0
  30. ctrlcode/fuzzing/context.py +665 -0
  31. ctrlcode/fuzzing/context_fuzzer.py +506 -0
  32. ctrlcode/fuzzing/derived_orchestrator.py +732 -0
  33. ctrlcode/fuzzing/oracle_adapter.py +135 -0
  34. ctrlcode/linters/__init__.py +11 -0
  35. ctrlcode/linters/hand_rolled_utils.py +221 -0
  36. ctrlcode/linters/yolo_parsing.py +217 -0
  37. ctrlcode/metrics/__init__.py +6 -0
  38. ctrlcode/metrics/dashboard.py +283 -0
  39. ctrlcode/metrics/tech_debt.py +663 -0
  40. ctrlcode/paths.py +68 -0
  41. ctrlcode/permissions.py +179 -0
  42. ctrlcode/providers/__init__.py +15 -0
  43. ctrlcode/providers/anthropic.py +138 -0
  44. ctrlcode/providers/base.py +77 -0
  45. ctrlcode/providers/openai.py +197 -0
  46. ctrlcode/providers/parallel.py +104 -0
  47. ctrlcode/server.py +871 -0
  48. ctrlcode/session/__init__.py +6 -0
  49. ctrlcode/session/baseline.py +57 -0
  50. ctrlcode/session/manager.py +967 -0
  51. ctrlcode/skills/__init__.py +10 -0
  52. ctrlcode/skills/builtin/commit.toml +29 -0
  53. ctrlcode/skills/builtin/docs.toml +25 -0
  54. ctrlcode/skills/builtin/refactor.toml +33 -0
  55. ctrlcode/skills/builtin/review.toml +28 -0
  56. ctrlcode/skills/builtin/test.toml +28 -0
  57. ctrlcode/skills/loader.py +111 -0
  58. ctrlcode/skills/registry.py +139 -0
  59. ctrlcode/storage/__init__.py +19 -0
  60. ctrlcode/storage/history_db.py +708 -0
  61. ctrlcode/tools/__init__.py +220 -0
  62. ctrlcode/tools/bash.py +112 -0
  63. ctrlcode/tools/browser.py +352 -0
  64. ctrlcode/tools/executor.py +153 -0
  65. ctrlcode/tools/explore.py +486 -0
  66. ctrlcode/tools/mcp.py +108 -0
  67. ctrlcode/tools/observability.py +561 -0
  68. ctrlcode/tools/registry.py +193 -0
  69. ctrlcode/tools/todo.py +291 -0
  70. ctrlcode/tools/update.py +266 -0
  71. ctrlcode/tools/webfetch.py +147 -0
  72. ctrlcode-0.1.0.dist-info/METADATA +93 -0
  73. ctrlcode-0.1.0.dist-info/RECORD +75 -0
  74. ctrlcode-0.1.0.dist-info/WHEEL +4 -0
  75. ctrlcode-0.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,297 @@
1
+ """Reusable ReAct loop execution for agents and sessions."""
2
+
3
+ import logging
4
+ import json
5
+ from typing import Any, Callable
6
+ from dataclasses import dataclass
7
+
8
+ from harnessutils import ConversationManager, Message, TextPart
9
+
10
+ from ..providers.base import Provider, StreamEvent
11
+ from ..tools.executor import ToolExecutor
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ @dataclass
17
+ class ReActResult:
18
+ """Result from executing a ReAct loop."""
19
+
20
+ assistant_text: str
21
+ tool_calls: list[dict[str, Any]]
22
+ usage_tokens: int
23
+ continuation_count: int
24
+
25
+
26
+ class AgentReActLoop:
27
+ """Execute ReAct (Reason + Act) loops with tool calling."""
28
+
29
+ def __init__(self):
30
+ """Initialize ReAct loop executor."""
31
+ self.msg_id_counter = 0
32
+
33
+ def _generate_msg_id(self) -> str:
34
+ """Generate unique message ID."""
35
+ self.msg_id_counter += 1
36
+ return f"msg_{self.msg_id_counter}"
37
+
38
+ async def execute(
39
+ self,
40
+ provider: Provider,
41
+ conv_manager: ConversationManager,
42
+ conv_id: str,
43
+ tool_executor: ToolExecutor,
44
+ tools: list[dict[str, Any]],
45
+ allowed_tools: list[str] | None = None,
46
+ max_continuations: int = 50,
47
+ event_callback: Callable[[StreamEvent], None] | None = None,
48
+ ) -> ReActResult:
49
+ """
50
+ Execute ReAct loop with tool calling.
51
+
52
+ Args:
53
+ provider: LLM provider to stream from
54
+ conv_manager: Conversation manager
55
+ conv_id: Conversation ID
56
+ tool_executor: Tool executor for running tools
57
+ tools: Tool definitions to pass to LLM
58
+ allowed_tools: Optional list of allowed tool names (for filtering)
59
+ max_continuations: Maximum continuation iterations
60
+ event_callback: Optional callback for streaming events
61
+
62
+ Returns:
63
+ ReActResult with aggregated output
64
+ """
65
+ usage_tokens = 0
66
+ accumulated_text: list[str] = []
67
+ all_tool_calls: list[dict] = []
68
+
69
+ # Get current messages
70
+ messages = conv_manager.to_model_format(conv_id)
71
+
72
+ # First pass: stream and collect tool calls
73
+ assistant_text = []
74
+ tool_calls: list[dict] = []
75
+ current_tool_call: dict | None = None
76
+
77
+ async for event in provider.stream(messages, tools=tools):
78
+ if event_callback:
79
+ event_callback(event)
80
+
81
+ if event.type == "text":
82
+ text_chunk = event.data["text"]
83
+ assistant_text.append(text_chunk)
84
+ accumulated_text.append(text_chunk)
85
+
86
+ elif event.type == "usage":
87
+ usage = event.data.get("usage", {})
88
+ usage_tokens += usage.get("completion_tokens", 0)
89
+
90
+ elif event.type == "tool_call_start":
91
+ current_tool_call = {
92
+ "tool": event.data["tool"],
93
+ "call_id": event.data["call_id"],
94
+ "input": ""
95
+ }
96
+
97
+ elif event.type == "tool_call_delta":
98
+ if current_tool_call:
99
+ current_tool_call["input"] += event.data.get("delta", "")
100
+
101
+ elif event.type == "content_block_stop":
102
+ if current_tool_call:
103
+ try:
104
+ current_tool_call["input"] = json.loads(current_tool_call["input"])
105
+ except json.JSONDecodeError:
106
+ current_tool_call["input"] = {}
107
+ tool_calls.append(current_tool_call)
108
+ all_tool_calls.append(current_tool_call)
109
+ current_tool_call = None
110
+
111
+ # Execute tools if any were called
112
+ if tool_calls and tool_executor:
113
+ # Add assistant message with tool use
114
+ assistant_msg = Message(id=self._generate_msg_id(), role="assistant")
115
+ if assistant_text:
116
+ assistant_msg.add_part(TextPart(text="".join(assistant_text)))
117
+ conv_manager.add_message(conv_id, assistant_msg)
118
+
119
+ # Execute all tools
120
+ for tool_call in tool_calls:
121
+ # Filter by allowed_tools if specified
122
+ if allowed_tools and tool_call["tool"] not in allowed_tools:
123
+ logger.warning(f"Tool {tool_call['tool']} not in allowed list, skipping")
124
+ continue
125
+
126
+ result = await tool_executor.execute(
127
+ tool_name=tool_call["tool"],
128
+ arguments=tool_call["input"],
129
+ call_id=tool_call["call_id"]
130
+ )
131
+
132
+ # Emit tool result event
133
+ if event_callback:
134
+ event_callback(StreamEvent(
135
+ type="tool_result",
136
+ data={
137
+ "tool": tool_call["tool"],
138
+ "success": result.success,
139
+ "result": result.result if result.success else result.error
140
+ }
141
+ ))
142
+
143
+ # Add tool result message
144
+ tool_result_msg = Message(id=self._generate_msg_id(), role="user")
145
+ result_text = f"[Tool: {tool_call['tool']}]\n"
146
+ if result.success:
147
+ result_text += f"Result: {result.result}"
148
+ else:
149
+ result_text += f"Error: {result.error}"
150
+ tool_result_msg.add_part(TextPart(text=result_text))
151
+ conv_manager.add_message(conv_id, tool_result_msg)
152
+
153
+ # Continuation loop: keep calling tools until LLM stops
154
+ continuation_count = 0
155
+
156
+ while tool_calls and continuation_count < max_continuations:
157
+ continuation_count += 1
158
+ logger.info(f"ReAct continuation {continuation_count}/{max_continuations}")
159
+
160
+ # Add continuation prompt
161
+ reminder_msg = Message(id=self._generate_msg_id(), role="user")
162
+ reminder_msg.add_part(TextPart(
163
+ text="Continue using tools to complete the task. Call more tools if needed."
164
+ ))
165
+ conv_manager.add_message(conv_id, reminder_msg)
166
+
167
+ # Get updated messages
168
+ messages = conv_manager.to_model_format(conv_id)
169
+
170
+ # Stream continuation
171
+ continuation_text = []
172
+ continuation_tool_calls = []
173
+ current_continuation_tool = None
174
+
175
+ async for event in provider.stream(messages, tools=tools):
176
+ if event_callback:
177
+ event_callback(event)
178
+
179
+ if event.type == "text":
180
+ continuation_text.append(event.data["text"])
181
+ accumulated_text.append(event.data["text"])
182
+
183
+ elif event.type == "usage":
184
+ usage = event.data.get("usage", {})
185
+ usage_tokens += usage.get("completion_tokens", 0)
186
+
187
+ elif event.type == "tool_call_start":
188
+ current_continuation_tool = {
189
+ "tool": event.data["tool"],
190
+ "call_id": event.data["call_id"],
191
+ "input": ""
192
+ }
193
+
194
+ elif event.type == "tool_call_delta":
195
+ if current_continuation_tool:
196
+ current_continuation_tool["input"] += event.data.get("delta", "")
197
+
198
+ elif event.type == "content_block_stop":
199
+ if current_continuation_tool:
200
+ try:
201
+ current_continuation_tool["input"] = json.loads(
202
+ current_continuation_tool["input"]
203
+ )
204
+ except json.JSONDecodeError:
205
+ current_continuation_tool["input"] = {}
206
+ continuation_tool_calls.append(current_continuation_tool)
207
+ all_tool_calls.append(current_continuation_tool)
208
+ current_continuation_tool = None
209
+
210
+ # Add continuation assistant message
211
+ cont_assistant_msg = Message(id=self._generate_msg_id(), role="assistant")
212
+ if continuation_text:
213
+ cont_assistant_msg.add_part(TextPart(text="".join(continuation_text)))
214
+ conv_manager.add_message(conv_id, cont_assistant_msg)
215
+
216
+ # Execute continuation tools
217
+ if continuation_tool_calls:
218
+ for tool_call in continuation_tool_calls:
219
+ # Filter by allowed_tools
220
+ if allowed_tools and tool_call["tool"] not in allowed_tools:
221
+ logger.warning(f"Tool {tool_call['tool']} not allowed, skipping")
222
+ continue
223
+
224
+ result = await tool_executor.execute(
225
+ tool_name=tool_call["tool"],
226
+ arguments=tool_call["input"],
227
+ call_id=tool_call["call_id"]
228
+ )
229
+
230
+ # Emit tool result
231
+ if event_callback:
232
+ event_callback(StreamEvent(
233
+ type="tool_result",
234
+ data={
235
+ "tool": tool_call["tool"],
236
+ "success": result.success,
237
+ "result": result.result if result.success else result.error,
238
+ "continuation": True,
239
+ "continuation_index": continuation_count
240
+ }
241
+ ))
242
+
243
+ # Add tool result message
244
+ tool_result_msg = Message(id=self._generate_msg_id(), role="user")
245
+ result_text = f"[Tool: {tool_call['tool']}]\n"
246
+ if result.success:
247
+ result_text += f"Result: {result.result}"
248
+ else:
249
+ result_text += f"Error: {result.error}"
250
+ tool_result_msg.add_part(TextPart(text=result_text))
251
+ conv_manager.add_message(conv_id, tool_result_msg)
252
+
253
+ # Update tool_calls for next iteration
254
+ tool_calls = continuation_tool_calls
255
+ else:
256
+ # No more tools called, exit loop
257
+ logger.info(f"ReAct loop ending after {continuation_count} continuations")
258
+ break
259
+
260
+ # Emit continuation summary
261
+ if event_callback:
262
+ event_callback(StreamEvent(
263
+ type="continuation_complete",
264
+ data={
265
+ "iterations": continuation_count,
266
+ "tool_count": len(all_tool_calls)
267
+ }
268
+ ))
269
+
270
+ # Final summary call (no tools)
271
+ messages = conv_manager.to_model_format(conv_id)
272
+ final_text = []
273
+
274
+ async for event in provider.stream(messages, tools=tools):
275
+ if event_callback:
276
+ event_callback(event)
277
+
278
+ if event.type == "text":
279
+ final_text.append(event.data["text"])
280
+ accumulated_text.append(event.data["text"])
281
+
282
+ elif event.type == "usage":
283
+ usage = event.data.get("usage", {})
284
+ usage_tokens += usage.get("completion_tokens", 0)
285
+
286
+ # Add final assistant message
287
+ if final_text:
288
+ final_msg = Message(id=self._generate_msg_id(), role="assistant")
289
+ final_msg.add_part(TextPart(text="".join(final_text)))
290
+ conv_manager.add_message(conv_id, final_msg)
291
+
292
+ return ReActResult(
293
+ assistant_text="".join(accumulated_text),
294
+ tool_calls=all_tool_calls,
295
+ usage_tokens=usage_tokens,
296
+ continuation_count=continuation_count if tool_calls else 0
297
+ )
@@ -0,0 +1,211 @@
1
+ """Agent registry for creating specialized agents."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ # Note: harness-utils integration will be added when we integrate
7
+ # For now, this is the structure/interface
8
+
9
+
10
+ class AgentConfig:
11
+ """Configuration for a specialized agent."""
12
+
13
+ def __init__(
14
+ self,
15
+ name: str,
16
+ system_prompt_path: str,
17
+ tools: list[str],
18
+ prune_protect: int = 50_000,
19
+ prune_minimum: int = 25_000,
20
+ max_lines: int = 3000,
21
+ use_predictive: bool = True,
22
+ ):
23
+ """
24
+ Initialize agent configuration.
25
+
26
+ Args:
27
+ name: Agent identifier (e.g., "planner", "coder")
28
+ system_prompt_path: Path to agent-specific prompt file
29
+ tools: List of allowed tool names
30
+ prune_protect: Context size before pruning starts (tokens)
31
+ prune_minimum: Minimum context after pruning (tokens)
32
+ max_lines: Maximum lines in tool output
33
+ use_predictive: Enable predictive overflow detection
34
+ """
35
+ self.name = name
36
+ self.system_prompt_path = system_prompt_path
37
+ self.tools = tools
38
+ self.prune_protect = prune_protect
39
+ self.prune_minimum = prune_minimum
40
+ self.max_lines = max_lines
41
+ self.use_predictive = use_predictive
42
+
43
+
44
+ class AgentRegistry:
45
+ """Registry of specialized agent configurations."""
46
+
47
+ def __init__(self, workspace_root: Path, tool_registry: Any):
48
+ """
49
+ Initialize agent registry.
50
+
51
+ Args:
52
+ workspace_root: Root directory of workspace
53
+ tool_registry: ToolRegistry instance
54
+ """
55
+ self.workspace = workspace_root
56
+ self.tools = tool_registry
57
+ self.prompts_dir = workspace_root / "prompts" / "agents"
58
+
59
+ def get_planner_config(self) -> AgentConfig:
60
+ """Get configuration for Planner agent."""
61
+ return AgentConfig(
62
+ name="planner",
63
+ system_prompt_path=str(self.prompts_dir / "planner.md"),
64
+ tools=[
65
+ "search_files",
66
+ "search_code",
67
+ "read_file",
68
+ "list_directory",
69
+ "task_write",
70
+ ],
71
+ prune_protect=50_000, # Moderate - needs context for planning
72
+ prune_minimum=25_000,
73
+ max_lines=3000,
74
+ use_predictive=True,
75
+ )
76
+
77
+ def get_coder_config(self, task_id: str | None = None) -> AgentConfig:
78
+ """
79
+ Get configuration for Coder agent.
80
+
81
+ Args:
82
+ task_id: Optional task identifier for naming
83
+ """
84
+ name = f"coder-{task_id}" if task_id else "coder"
85
+
86
+ return AgentConfig(
87
+ name=name,
88
+ system_prompt_path=str(self.prompts_dir / "coder.md"),
89
+ tools=[
90
+ "read_file",
91
+ "write_file",
92
+ "update_file",
93
+ "run_command",
94
+ "search_code",
95
+ ],
96
+ prune_protect=100_000, # Large - needs full context for coding
97
+ prune_minimum=50_000,
98
+ max_lines=5000,
99
+ use_predictive=True,
100
+ )
101
+
102
+ def get_reviewer_config(self) -> AgentConfig:
103
+ """Get configuration for Reviewer agent."""
104
+ return AgentConfig(
105
+ name="reviewer",
106
+ system_prompt_path=str(self.prompts_dir / "reviewer.md"),
107
+ tools=[
108
+ "read_file",
109
+ "run_command",
110
+ "search_code",
111
+ "task_update",
112
+ ],
113
+ prune_protect=75_000, # Moderate-large - needs context for review
114
+ prune_minimum=40_000,
115
+ max_lines=4000,
116
+ use_predictive=True,
117
+ )
118
+
119
+ def get_executor_config(self) -> AgentConfig:
120
+ """Get configuration for Executor agent."""
121
+ return AgentConfig(
122
+ name="executor",
123
+ system_prompt_path=str(self.prompts_dir / "executor.md"),
124
+ tools=[
125
+ "run_command",
126
+ "fetch",
127
+ "query_logs",
128
+ "query_metrics",
129
+ "screenshot",
130
+ "dom_snapshot",
131
+ ],
132
+ prune_protect=50_000, # Moderate - runtime validation
133
+ prune_minimum=25_000,
134
+ max_lines=3000,
135
+ use_predictive=True,
136
+ )
137
+
138
+ def get_orchestrator_config(self) -> AgentConfig:
139
+ """Get configuration for Orchestrator agent."""
140
+ return AgentConfig(
141
+ name="orchestrator",
142
+ system_prompt_path=str(self.prompts_dir / "orchestrator.md"),
143
+ tools=[], # Orchestrator delegates, doesn't use tools directly
144
+ prune_protect=30_000, # Small - just coordinates
145
+ prune_minimum=15_000,
146
+ max_lines=2000,
147
+ use_predictive=True,
148
+ )
149
+
150
+ def get_agent_configs(self) -> dict[str, AgentConfig]:
151
+ """
152
+ Get all agent configurations.
153
+
154
+ Returns:
155
+ Dict mapping agent type to AgentConfig
156
+ """
157
+ return {
158
+ "planner": self.get_planner_config(),
159
+ "coder": self.get_coder_config(),
160
+ "reviewer": self.get_reviewer_config(),
161
+ "executor": self.get_executor_config(),
162
+ "orchestrator": self.get_orchestrator_config(),
163
+ }
164
+
165
+ def load_system_prompt(self, agent_type: str) -> str:
166
+ """
167
+ Load system prompt for agent type.
168
+
169
+ Args:
170
+ agent_type: Agent type (planner, coder, reviewer, executor, orchestrator)
171
+
172
+ Returns:
173
+ System prompt content
174
+
175
+ Raises:
176
+ FileNotFoundError: If prompt file doesn't exist
177
+ """
178
+ config = self.get_agent_configs()[agent_type]
179
+ prompt_path = Path(config.system_prompt_path)
180
+
181
+ if not prompt_path.exists():
182
+ raise FileNotFoundError(f"Prompt file not found: {prompt_path}")
183
+
184
+ return prompt_path.read_text()
185
+
186
+ def get_allowed_tools(self, agent_type: str) -> list[str]:
187
+ """
188
+ Get list of tools allowed for agent type.
189
+
190
+ Args:
191
+ agent_type: Agent type
192
+
193
+ Returns:
194
+ List of tool names
195
+ """
196
+ config = self.get_agent_configs()[agent_type]
197
+ return config.tools
198
+
199
+ def validate_tool_access(self, agent_type: str, tool_name: str) -> bool:
200
+ """
201
+ Check if agent is allowed to use tool.
202
+
203
+ Args:
204
+ agent_type: Agent type
205
+ tool_name: Tool to check
206
+
207
+ Returns:
208
+ True if allowed, False otherwise
209
+ """
210
+ allowed_tools = self.get_allowed_tools(agent_type)
211
+ return tool_name in allowed_tools