agentforge-harness 0.1.2__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 (72) hide show
  1. agentforge_harness/__init__.py +3 -0
  2. agentforge_harness/agent/__init__.py +0 -0
  3. agentforge_harness/agent/agent.py +332 -0
  4. agentforge_harness/agent/events.py +106 -0
  5. agentforge_harness/agent/modes.py +6 -0
  6. agentforge_harness/agent/persistence.py +258 -0
  7. agentforge_harness/agent/session.py +297 -0
  8. agentforge_harness/cli/__init__.py +0 -0
  9. agentforge_harness/cli/commands.py +860 -0
  10. agentforge_harness/cli/doctor.py +819 -0
  11. agentforge_harness/cli/models.py +146 -0
  12. agentforge_harness/cli/report.py +249 -0
  13. agentforge_harness/cli/run.py +163 -0
  14. agentforge_harness/cli/setup.py +275 -0
  15. agentforge_harness/client/__init__.py +0 -0
  16. agentforge_harness/client/llm_client.py +410 -0
  17. agentforge_harness/client/response.py +87 -0
  18. agentforge_harness/config/__init__.py +0 -0
  19. agentforge_harness/config/config.py +246 -0
  20. agentforge_harness/config/loader.py +170 -0
  21. agentforge_harness/context/__init__.py +0 -0
  22. agentforge_harness/context/compaction.py +93 -0
  23. agentforge_harness/context/loop_detector.py +51 -0
  24. agentforge_harness/context/manager.py +290 -0
  25. agentforge_harness/hooks/__init__.py +0 -0
  26. agentforge_harness/hooks/hook_system.py +152 -0
  27. agentforge_harness/prompts/__init__.py +0 -0
  28. agentforge_harness/prompts/system.py +381 -0
  29. agentforge_harness/safety/__init__.py +0 -0
  30. agentforge_harness/safety/approval.py +187 -0
  31. agentforge_harness/safety/circuit_breaker.py +78 -0
  32. agentforge_harness/safety/output_hygiene.py +169 -0
  33. agentforge_harness/safety/prompt_injection.py +58 -0
  34. agentforge_harness/skills/__init__.py +0 -0
  35. agentforge_harness/skills/manager.py +473 -0
  36. agentforge_harness/tools/__init__.py +0 -0
  37. agentforge_harness/tools/base.py +231 -0
  38. agentforge_harness/tools/builtin/__init__.py +50 -0
  39. agentforge_harness/tools/builtin/append_file.py +121 -0
  40. agentforge_harness/tools/builtin/edit_file.py +241 -0
  41. agentforge_harness/tools/builtin/git_diff.py +182 -0
  42. agentforge_harness/tools/builtin/glob.py +68 -0
  43. agentforge_harness/tools/builtin/grep.py +132 -0
  44. agentforge_harness/tools/builtin/list_dir.py +76 -0
  45. agentforge_harness/tools/builtin/memory.py +155 -0
  46. agentforge_harness/tools/builtin/patch.py +566 -0
  47. agentforge_harness/tools/builtin/read_file.py +169 -0
  48. agentforge_harness/tools/builtin/shell.py +184 -0
  49. agentforge_harness/tools/builtin/todo.py +92 -0
  50. agentforge_harness/tools/builtin/web_fetch.py +68 -0
  51. agentforge_harness/tools/builtin/web_search.py +72 -0
  52. agentforge_harness/tools/builtin/write_file.py +120 -0
  53. agentforge_harness/tools/discovery.py +74 -0
  54. agentforge_harness/tools/mcp/__init__.py +0 -0
  55. agentforge_harness/tools/mcp/client.py +103 -0
  56. agentforge_harness/tools/mcp/mcp_manager.py +91 -0
  57. agentforge_harness/tools/mcp/mcp_tool.py +54 -0
  58. agentforge_harness/tools/registry.py +191 -0
  59. agentforge_harness/tools/subagents.py +226 -0
  60. agentforge_harness/ui/__init__.py +0 -0
  61. agentforge_harness/ui/tui.py +1142 -0
  62. agentforge_harness/utils/__init__.py +0 -0
  63. agentforge_harness/utils/errors.py +49 -0
  64. agentforge_harness/utils/paths.py +55 -0
  65. agentforge_harness/utils/redaction.py +213 -0
  66. agentforge_harness/utils/text.py +78 -0
  67. agentforge_harness-0.1.2.dist-info/METADATA +1028 -0
  68. agentforge_harness-0.1.2.dist-info/RECORD +72 -0
  69. agentforge_harness-0.1.2.dist-info/WHEEL +5 -0
  70. agentforge_harness-0.1.2.dist-info/entry_points.txt +2 -0
  71. agentforge_harness-0.1.2.dist-info/licenses/LICENSE +21 -0
  72. agentforge_harness-0.1.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3 @@
1
+ """AgentForge harness package."""
2
+
3
+ __version__ = "0.1.0"
File without changes
@@ -0,0 +1,332 @@
1
+ from __future__ import annotations
2
+ import asyncio
3
+ import json
4
+ import logging
5
+ import random
6
+ from typing import AsyncGenerator, Callable
7
+ from agentforge_harness.agent.events import AgentEvent, AgentEventType
8
+ from agentforge_harness.agent.modes import AgentMode
9
+ from agentforge_harness.agent.session import Session
10
+ from agentforge_harness.client.response import StreamEventType, TokenUsage, ToolCall, ToolResultMessage
11
+ from agentforge_harness.config.config import Config
12
+ from agentforge_harness.prompts.system import create_loop_breaker_prompt
13
+ from agentforge_harness.tools.base import ToolConfirmation, ToolResult
14
+ from agentforge_harness.utils.redaction import redact_tool_params
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class Agent:
20
+ def __init__(self, config: Config, confirmation_callback: Callable[[ToolConfirmation], bool] | None = None):
21
+ self.config = config
22
+ self.session: Session | None = Session(self.config)
23
+ self.session.approval_manager.confirmation_callback = confirmation_callback
24
+
25
+ async def run(self, message: str):
26
+ await self.session.hook_system.trigger_before_agent(message)
27
+ yield AgentEvent.agents_start(message)
28
+ self.session.context_manager.add_user_message(message)
29
+ self.session.loop_detector.clear()
30
+
31
+ final_response: str | None = None
32
+
33
+ async for event in self._agentic_loop():
34
+ yield event
35
+
36
+ if event.type == AgentEventType.TEXT_COMPLETE:
37
+ final_response = event.data.get("content")
38
+ await self.session.hook_system.trigger_after_agent(message, final_response or "")
39
+ yield AgentEvent.agents_end(final_response)
40
+
41
+ async def _agentic_loop(self) -> AsyncGenerator[AgentEvent, None]:
42
+ max_turns = self.config.max_turns
43
+ if self.session.mode == AgentMode.PLAN:
44
+ max_turns = min(max_turns, 8)
45
+ max_llm_retries = 3
46
+ plan_tool_budget = 8
47
+ plan_tool_calls = 0
48
+ force_plan_response = False
49
+
50
+ model_chain = [
51
+ self.config.model_name,
52
+ *(self.config.model.fallbacks or []),
53
+ ]
54
+ circuit_breaker = self.session.circuit_breaker
55
+
56
+ try:
57
+ for turn_num in range(max_turns):
58
+ self.session.increment_turn()
59
+
60
+ # check context budget and auto-compress if needed
61
+ budget = self.session.context_manager.get_context_budget()
62
+ if budget["warning"]:
63
+ if budget["total_tokens"] > 0:
64
+ yield AgentEvent.text_delta(
65
+ f"\n[Context: {budget['usage_pct']}% ({budget['total_tokens']}/{budget['context_window']} tokens)]"
66
+ )
67
+ if budget["critical"] or budget["usage_pct"] >= 80:
68
+ summary, usage = await self.session.context_manager.compress_old_messages(
69
+ self.session.chat_compactor
70
+ )
71
+ if summary and usage:
72
+ self.session.context_manager.set_latest_usage(usage)
73
+ self.session.context_manager.add_usage(usage)
74
+
75
+ tool_schemas = (
76
+ []
77
+ if force_plan_response
78
+ else self.session.tool_registry.get_schemas(mode=self.session.mode)
79
+ )
80
+
81
+ # LLM call with circuit breaker + fallback chain
82
+ response_text = ""
83
+ tool_calls: list[ToolCall] = []
84
+ usage: TokenUsage | None = None
85
+ llm_success = False
86
+ selected_model = model_chain[0]
87
+
88
+ for model_idx, model_name in enumerate(model_chain):
89
+ if circuit_breaker.is_open(model_name):
90
+ yield AgentEvent.text_delta(
91
+ f"\n[Skipping {model_name} (circuit open)]"
92
+ )
93
+ continue
94
+
95
+ for attempt in range(max_llm_retries + 1):
96
+ response_text = ""
97
+ tool_calls = []
98
+ usage = None
99
+ saw_error = False
100
+
101
+ async for event in self.session.client.chat_completion(
102
+ self.session.context_manager.get_messages(),
103
+ tools=tool_schemas if tool_schemas else None,
104
+ model=model_name,
105
+ max_retries=0,
106
+ ):
107
+ if event.type == StreamEventType.TEXT_DELTA:
108
+ if event.text_delta:
109
+ content = event.text_delta.content
110
+ response_text += content
111
+ yield AgentEvent.text_delta(content)
112
+ elif event.type == StreamEventType.TOOL_CALL_COMPLETE:
113
+ if event.tool_call:
114
+ tool_calls.append(event.tool_call)
115
+ elif event.type == StreamEventType.ERROR:
116
+ circuit_breaker.record_failure(model_name)
117
+ if attempt < max_llm_retries and circuit_breaker.can_try(model_name):
118
+ wait = 2 ** attempt + random.uniform(0, 1)
119
+ err_msg = event.error or "unknown error"
120
+ yield AgentEvent.text_delta(
121
+ f"\n[{model_name} error: {err_msg}, retrying in {wait:.1f}s...]"
122
+ )
123
+ await asyncio.sleep(wait)
124
+ saw_error = True
125
+ break
126
+ elif attempt < max_llm_retries:
127
+ yield AgentEvent.text_delta(
128
+ f"\n[{model_name} circuit open after {circuit_breaker.failure_threshold} failures, trying fallback...]"
129
+ )
130
+ saw_error = True
131
+ break
132
+ else:
133
+ yield AgentEvent.text_delta(
134
+ f"\n[{model_name} failed after {max_llm_retries + 1} attempts, trying fallback...]"
135
+ )
136
+ saw_error = True
137
+ break
138
+ elif event.type == StreamEventType.MESSAGE_COMPLETE:
139
+ usage = event.token_usage
140
+
141
+ if saw_error:
142
+ continue
143
+
144
+ circuit_breaker.record_success(model_name)
145
+ llm_success = True
146
+ selected_model = model_name
147
+ break
148
+
149
+ if llm_success:
150
+ break
151
+
152
+ if not llm_success:
153
+ yield AgentEvent.agents_error(
154
+ f"All models exhausted. Tried: {', '.join(model_chain)}. "
155
+ "Check API keys and network connectivity."
156
+ )
157
+ return
158
+
159
+ if selected_model != model_chain[0]:
160
+ yield AgentEvent.text_delta(
161
+ f"\n[Failed over to {selected_model}]\n"
162
+ )
163
+
164
+ self.session.context_manager.add_assistant_message(
165
+ response_text or None,
166
+ (
167
+ [
168
+ {
169
+ "id": tc.call_id,
170
+ "type": "function",
171
+ "function": {
172
+ "name": tc.name,
173
+ "arguments": json.dumps(tc.arguments),
174
+ },
175
+ }
176
+ for tc in tool_calls
177
+ ]
178
+ if tool_calls
179
+ else None
180
+ ),
181
+ )
182
+
183
+ yield AgentEvent.text_complete(response_text)
184
+ if response_text:
185
+ self.session.loop_detector.record_action("response", text=response_text)
186
+
187
+ if not tool_calls:
188
+ if usage:
189
+ self.session.context_manager.set_latest_usage(usage)
190
+ self.session.context_manager.add_usage(usage)
191
+
192
+ self.session.context_manager.prune_tool_outputs()
193
+ return
194
+
195
+ tool_call_results: list[ToolResultMessage] = []
196
+
197
+ for tool_call in tool_calls:
198
+ display_arguments = self._display_tool_arguments(tool_call.arguments)
199
+ yield AgentEvent.tool_call_start(
200
+ tool_call.call_id,
201
+ tool_call.name,
202
+ display_arguments,
203
+ )
204
+
205
+ skip_tool_reason: str | None = None
206
+ self.session.loop_detector.record_action(
207
+ "tool_call",
208
+ tool_name=tool_call.name,
209
+ args=tool_call.arguments,
210
+ )
211
+
212
+ if self.session.mode == AgentMode.PLAN:
213
+ plan_tool_calls += 1
214
+ if plan_tool_calls > plan_tool_budget:
215
+ skip_tool_reason = (
216
+ f"Plan mode read-only exploration limit reached "
217
+ f"({plan_tool_budget} tool call(s))."
218
+ )
219
+ elif loop_detection_error := self.session.loop_detector.check_for_loop():
220
+ skip_tool_reason = (
221
+ f"Plan mode stopped repeated tool exploration: "
222
+ f"{loop_detection_error}."
223
+ )
224
+
225
+ if skip_tool_reason:
226
+ result = ToolResult.error_result(
227
+ f"{skip_tool_reason} Stop calling tools and provide the plan now."
228
+ )
229
+ force_plan_response = True
230
+ else:
231
+ try:
232
+ result = await self.session.tool_registry.invoke(
233
+ tool_call.name,
234
+ tool_call.arguments,
235
+ self.config.cwd,
236
+ self.session.hook_system,
237
+ self.session.approval_manager,
238
+ )
239
+ except Exception as e:
240
+ logger.warning(
241
+ "Tool '%s' crashed: %s",
242
+ tool_call.name,
243
+ e,
244
+ )
245
+ yield AgentEvent.text_delta(
246
+ f"\n[Tool '{tool_call.name}' crashed: {e}]"
247
+ )
248
+ result = ToolResult.error_result(f"Tool crashed: {e}")
249
+
250
+ if skip_tool_reason:
251
+ yield AgentEvent.text_delta(
252
+ f"\n[{skip_tool_reason} Preparing a plan now.]"
253
+ )
254
+
255
+ yield AgentEvent.tool_call_complete(
256
+ tool_call.call_id,
257
+ tool_call.name,
258
+ result,
259
+ )
260
+
261
+ tool_call_results.append(
262
+ ToolResultMessage(
263
+ tool_call_id=tool_call.call_id,
264
+ content=result.to_model_output(),
265
+ is_error=not result.success,
266
+ )
267
+ )
268
+
269
+ for tool_result in tool_call_results:
270
+ self.session.context_manager.add_tool_result(
271
+ tool_result.tool_call_id,
272
+ tool_result.content,
273
+ )
274
+
275
+ if force_plan_response and self.session.mode == AgentMode.PLAN:
276
+ self.session.context_manager.add_user_message(
277
+ "SYSTEM NOTICE: Plan mode has enough context or is repeating tool exploration. "
278
+ "Do not call more tools. Produce the final plan now, with goal, approach, steps, "
279
+ "files to change, open questions, and the reminder to switch to /build for implementation."
280
+ )
281
+ self.session.loop_detector.clear()
282
+ self.session.context_manager.prune_tool_outputs()
283
+ continue
284
+
285
+ if usage:
286
+ self.session.context_manager.set_latest_usage(usage)
287
+ self.session.context_manager.add_usage(usage)
288
+
289
+ loop_detection_error = self.session.loop_detector.check_for_loop()
290
+ if loop_detection_error:
291
+ loop_prompt = create_loop_breaker_prompt(loop_detection_error)
292
+ self.session.context_manager.add_user_message(loop_prompt)
293
+ self.session.loop_detector.clear()
294
+ self.session.context_manager.prune_tool_outputs()
295
+ continue
296
+
297
+ self.session.context_manager.prune_tool_outputs()
298
+
299
+ yield AgentEvent.agents_error(f"Maximum turns ({max_turns}) reached")
300
+
301
+ except Exception as e:
302
+ logger.exception("Unhandled exception in agent loop")
303
+ try:
304
+ self.session.save_checkpoint(mode="crash")
305
+ except Exception:
306
+ logger.warning("Failed to save crash checkpoint")
307
+ yield AgentEvent.agents_error(
308
+ f"Internal agent error: {str(e)}",
309
+ details={"turn": self.session._turn_count},
310
+ )
311
+ return
312
+
313
+ def _display_tool_arguments(self, arguments: dict) -> dict:
314
+ if not self.config.redaction_enabled:
315
+ return arguments
316
+ redacted, _ = redact_tool_params(arguments)
317
+ return redacted
318
+
319
+ async def __aenter__(self) -> Agent:
320
+ await self.session.initialize()
321
+ return self
322
+
323
+ async def __aexit__(
324
+ self,
325
+ exc_type,
326
+ exc_val,
327
+ exc_tb,
328
+ ) -> None:
329
+ if self.session and self.session.client and self.session.mcp_manager:
330
+ await self.session.client.close()
331
+ await self.session.mcp_manager.shutdown()
332
+ self.session = None
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import Enum
5
+ from typing import Any
6
+
7
+ from agentforge_harness.client.response import TokenUsage
8
+ from agentforge_harness.tools.base import ToolResult
9
+
10
+
11
+ class AgentEventType(str, Enum):
12
+ # Agent lifecycle events
13
+ AGENT_START = "agent_start"
14
+ AGENT_END = "agent_end"
15
+ AGENT_ERROR = "agent_error"
16
+
17
+ # Tool Calls
18
+ TOOL_CALL_START = "tool_call_start"
19
+ TOOL_CALL_COMPLETE = "tool_call_complete"
20
+
21
+ # Text Streaming Events
22
+ TEXT_DELTA = "text_delta"
23
+ TEXT_COMPLETE = "text_complete"
24
+
25
+
26
+ @dataclass
27
+ class AgentEvent:
28
+ type: AgentEventType
29
+ data: dict[str, Any] = field(default_factory=dict)
30
+
31
+ @classmethod
32
+ def agents_start(cls, message: str) -> AgentEvent:
33
+ return cls(
34
+ type=AgentEventType.AGENT_START,
35
+ data={"message": message},
36
+ )
37
+
38
+ @classmethod
39
+ def agents_end(
40
+ cls, response: str | None = None, usage: TokenUsage | None = None
41
+ ) -> AgentEvent:
42
+ return cls(
43
+ type=AgentEventType.AGENT_END,
44
+ data={
45
+ "response": response,
46
+ "usage": usage.__dict__ if usage else None,
47
+ },
48
+ )
49
+
50
+ @classmethod
51
+ def agents_error(
52
+ cls, error: str, details: dict[str, Any] | None = None
53
+ ) -> AgentEvent:
54
+ return cls(
55
+ type=AgentEventType.AGENT_ERROR,
56
+ data={
57
+ "error": error,
58
+ "details": details or {},
59
+ },
60
+ )
61
+
62
+ @classmethod
63
+ def text_delta(cls, content: str) -> AgentEvent:
64
+ return cls(
65
+ type=AgentEventType.TEXT_DELTA,
66
+ data={"content": content},
67
+ )
68
+
69
+ @classmethod
70
+ def text_complete(cls, content: str) -> AgentEvent:
71
+ return cls(
72
+ type=AgentEventType.TEXT_COMPLETE,
73
+ data={"content": content},
74
+ )
75
+
76
+ @classmethod
77
+ def tool_call_start(
78
+ cls, call_id: str, name: str, arguments: dict[str, Any]
79
+ ) -> AgentEvent:
80
+ return cls(
81
+ type=AgentEventType.TOOL_CALL_START,
82
+ data={
83
+ "call_id": call_id,
84
+ "name": name,
85
+ "arguments": arguments,
86
+ },
87
+ )
88
+
89
+ @classmethod
90
+ def tool_call_complete(
91
+ cls, call_id: str, name: str, result: ToolResult
92
+ ) -> AgentEvent:
93
+ return cls(
94
+ type=AgentEventType.TOOL_CALL_COMPLETE,
95
+ data={
96
+ "call_id": call_id,
97
+ "name": name,
98
+ "success": result.success,
99
+ "output": result.output,
100
+ "error": result.error,
101
+ "metadata": result.metadata,
102
+ "diff": result.diff_text,
103
+ "truncated": result.truncated,
104
+ "exit_code" : result.exit_code,
105
+ },
106
+ )
@@ -0,0 +1,6 @@
1
+ from enum import Enum
2
+
3
+
4
+ class AgentMode(str, Enum):
5
+ PLAN = "plan"
6
+ BUILD = "build"