tilde-agent 0.1.1__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 (66) hide show
  1. tilde/__init__.py +32 -0
  2. tilde/cli.py +283 -0
  3. tilde/logging.py +74 -0
  4. tilde/main.py +29 -0
  5. tilde/runtime/__init__.py +16 -0
  6. tilde/runtime/_streaming.py +261 -0
  7. tilde/runtime/agent.py +1806 -0
  8. tilde/runtime/context.py +194 -0
  9. tilde/runtime/events.py +327 -0
  10. tilde/runtime/mcp.py +321 -0
  11. tilde/runtime/model/__init__.py +53 -0
  12. tilde/runtime/model/anthropic.py +275 -0
  13. tilde/runtime/model/base.py +178 -0
  14. tilde/runtime/model/openai.py +284 -0
  15. tilde/runtime/model/openrouter.py +306 -0
  16. tilde/runtime/modes/__init__.py +21 -0
  17. tilde/runtime/modes/interactive.py +88 -0
  18. tilde/runtime/modes/json_mode.py +120 -0
  19. tilde/runtime/modes/print_mode.py +133 -0
  20. tilde/runtime/modes/rpc_mode.py +234 -0
  21. tilde/runtime/policy.py +586 -0
  22. tilde/runtime/queue.py +274 -0
  23. tilde/runtime/sandbox.py +129 -0
  24. tilde/runtime/session/__init__.py +11 -0
  25. tilde/runtime/session/compaction.py +83 -0
  26. tilde/runtime/session/manager.py +616 -0
  27. tilde/runtime/skills.py +158 -0
  28. tilde/runtime/tools/__init__.py +112 -0
  29. tilde/runtime/tools/base.py +187 -0
  30. tilde/runtime/tools/bash.py +314 -0
  31. tilde/runtime/tools/browser.py +318 -0
  32. tilde/runtime/tools/edit.py +127 -0
  33. tilde/runtime/tools/find.py +107 -0
  34. tilde/runtime/tools/github.py +160 -0
  35. tilde/runtime/tools/grep.py +150 -0
  36. tilde/runtime/tools/ls.py +96 -0
  37. tilde/runtime/tools/read.py +247 -0
  38. tilde/runtime/tools/skill.py +90 -0
  39. tilde/runtime/tools/subagent.py +115 -0
  40. tilde/runtime/tools/sysinfo.py +99 -0
  41. tilde/runtime/tools/web_search.py +167 -0
  42. tilde/runtime/tools/write.py +196 -0
  43. tilde/state/__init__.py +76 -0
  44. tilde/state/composer.py +47 -0
  45. tilde/state/context.py +113 -0
  46. tilde/state/execution.py +55 -0
  47. tilde/state/inspector.py +124 -0
  48. tilde/state/transcript.py +52 -0
  49. tilde/tracing.py +63 -0
  50. tilde/tui/__init__.py +19 -0
  51. tilde/tui/app.py +776 -0
  52. tilde/tui/components/__init__.py +18 -0
  53. tilde/tui/components/approval.py +51 -0
  54. tilde/tui/components/composer.py +348 -0
  55. tilde/tui/components/footer.py +176 -0
  56. tilde/tui/components/header.py +154 -0
  57. tilde/tui/components/inspector.py +142 -0
  58. tilde/tui/components/transcript.py +330 -0
  59. tilde/tui/display.py +216 -0
  60. tilde/tui/themes/__init__.py +5 -0
  61. tilde/tui/themes/tokens.py +26 -0
  62. tilde_agent-0.1.1.dist-info/METADATA +375 -0
  63. tilde_agent-0.1.1.dist-info/RECORD +66 -0
  64. tilde_agent-0.1.1.dist-info/WHEEL +4 -0
  65. tilde_agent-0.1.1.dist-info/entry_points.txt +3 -0
  66. tilde_agent-0.1.1.dist-info/licenses/LICENSE +21 -0
tilde/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ """
2
+ tilde - A production-grade, terminal-based AI/Agentic coding harness.
3
+
4
+ This package provides a Python implementation of an AI coding agent with:
5
+ - Enhanced security (7-Pillar architecture)
6
+ - Rich terminal UI (Rich + Textual)
7
+ - Multi-provider LLM support
8
+ - Built-in tools (read, write, bash, search, etc.)
9
+ - Web search and browser capabilities
10
+ - GitHub operations
11
+ - System monitoring
12
+ - Security auditing
13
+ - MCP support
14
+ - Subagents
15
+ - Plan mode
16
+ """
17
+
18
+ from importlib.metadata import PackageNotFoundError, version
19
+
20
+ try:
21
+ __version__ = version("tilde-agent")
22
+ except PackageNotFoundError: # source checkout without install
23
+ __version__ = "0.0.0+dev"
24
+
25
+ __author__ = "tilde Team"
26
+ __description__ = "Terminal-based AI/Agentic coding harness"
27
+
28
+ # Import key modules for convenience
29
+ from tilde.cli import app
30
+ from tilde.main import main
31
+
32
+ __all__ = ["__version__", "app", "main"]
tilde/cli.py ADDED
@@ -0,0 +1,283 @@
1
+ """
2
+ tilde CLI module.
3
+
4
+ This module defines the command-line interface for tilde using Typer.
5
+ It handles argument parsing, mode selection, and application initialization.
6
+ """
7
+
8
+ from pathlib import Path
9
+ from typing import Annotated
10
+
11
+ import typer
12
+
13
+ from tilde.logging import configure_logging, get_logger
14
+ from tilde.tracing import setup_tracing
15
+
16
+ # Module logger (configured at startup in main())
17
+ logger = get_logger(__name__)
18
+
19
+
20
+ # Create the Typer app
21
+ app = typer.Typer(
22
+ name="tilde",
23
+ help="A production-grade, terminal-based AI/Agentic coding harness",
24
+ no_args_is_help=True,
25
+ rich_markup_mode="rich",
26
+ )
27
+
28
+
29
+ @app.command()
30
+ def main(
31
+ # Model selection
32
+ model: Annotated[
33
+ str | None,
34
+ typer.Option(
35
+ "--model",
36
+ "-m",
37
+ help="Model to use (e.g., 'gpt-4', 'claude-3-5-sonnet', 'llama-3.1-70b')",
38
+ ),
39
+ ] = None,
40
+ # Provider selection
41
+ provider: Annotated[
42
+ str | None,
43
+ typer.Option(
44
+ "--provider",
45
+ "-p",
46
+ help="LLM provider (e.g., 'openai', 'anthropic', 'openrouter')",
47
+ ),
48
+ ] = None,
49
+ # Session management
50
+ session: Annotated[
51
+ str | None,
52
+ typer.Option(
53
+ "--session",
54
+ "-s",
55
+ help="Session name or ID to continue",
56
+ ),
57
+ ] = None,
58
+ continue_session: Annotated[
59
+ bool,
60
+ typer.Option(
61
+ "--continue",
62
+ "-c",
63
+ help="Continue the most recent session",
64
+ ),
65
+ ] = False,
66
+ no_session: Annotated[
67
+ bool,
68
+ typer.Option(
69
+ "--no-session",
70
+ help="Run in ephemeral mode without saving session",
71
+ ),
72
+ ] = False,
73
+ # Context files
74
+ context_files: Annotated[
75
+ bool,
76
+ typer.Option(
77
+ "--no-context-files",
78
+ "-nc",
79
+ help="Disable loading of AGENTS.md and other context files",
80
+ ),
81
+ ] = True, # Default to loading context files
82
+ # Mode selection
83
+ mode: Annotated[
84
+ str | None,
85
+ typer.Option(
86
+ "--mode",
87
+ help="Execution mode: 'interactive' (default), 'json', 'rpc', 'print'",
88
+ ),
89
+ ] = None,
90
+ # TUI mode
91
+ tui_mode: Annotated[
92
+ str | None,
93
+ typer.Option(
94
+ "--tui-mode",
95
+ help="TUI mode: 'alternate' (default), 'regular' (main-screen)",
96
+ ),
97
+ ] = None,
98
+ # Thinking level
99
+ thinking: Annotated[
100
+ str | None,
101
+ typer.Option(
102
+ "--thinking",
103
+ help="Thinking level: 'off', 'minimal', 'low', 'medium', 'high', 'max'",
104
+ ),
105
+ ] = None,
106
+ # Accept-edits mode (spec section 26.5)
107
+ accept_edits: Annotated[
108
+ bool,
109
+ typer.Option(
110
+ "--accept-edits",
111
+ help="Auto-approve project-local write/edit calls (bash still asks)",
112
+ ),
113
+ ] = False,
114
+ # Plan mode (read-only research + plan output)
115
+ plan: Annotated[
116
+ bool,
117
+ typer.Option(
118
+ "--plan",
119
+ help="Plan mode: read-only, write/edit/bash are refused",
120
+ ),
121
+ ] = False,
122
+ # Verbose output
123
+ verbose: Annotated[
124
+ bool,
125
+ typer.Option(
126
+ "--verbose",
127
+ "-v",
128
+ help="Enable verbose output",
129
+ ),
130
+ ] = False,
131
+ # Files to attach (repeatable option; only messages are positional)
132
+ files: Annotated[
133
+ list[Path] | None,
134
+ typer.Option(
135
+ "--file",
136
+ help="Files to attach to the prompt (repeatable, use @ prefix)",
137
+ ),
138
+ ] = None,
139
+ # Messages
140
+ messages: Annotated[
141
+ list[str] | None,
142
+ typer.Argument(
143
+ help="Initial messages/prompts",
144
+ ),
145
+ ] = None,
146
+ ) -> None:
147
+ """
148
+ Main tilde command.
149
+
150
+ This is the primary entry point for the tilde CLI. It handles:
151
+ - Model and provider selection
152
+ - Session management
153
+ - Context file loading
154
+ - Mode selection (interactive, JSON, RPC, print)
155
+ - File attachments
156
+ - Initial messages
157
+
158
+ Examples:
159
+ tilde "Fix the bug in auth.py"
160
+ tilde --model gpt-4 --provider openai "Review this PR"
161
+ tilde --continue
162
+ tilde --mode json "List all files"
163
+ tilde @src/auth.py "What does this do?"
164
+ """
165
+
166
+ # Initialize logging and tracing (once, for the whole app).
167
+ # Tracing stays off outside interactive mode so span output
168
+ # never pollutes machine-readable stdout (json/rpc/print).
169
+ configure_logging(verbose=verbose, json_format=(mode in ("json", "rpc")))
170
+ setup_tracing(enabled=(mode == "interactive"))
171
+
172
+ # Discover MCP tools before any mode builds tool schemas.
173
+ # Failures are logged, never fatal: MCP is optional.
174
+ import asyncio
175
+
176
+ from tilde.runtime import mcp as mcp_integration
177
+ from tilde.runtime import skills
178
+
179
+ try:
180
+ skills.discover()
181
+ except Exception as exc:
182
+ logger.error(f"Skill discovery failed: {exc}")
183
+
184
+ try:
185
+ discovered = asyncio.run(mcp_integration.discover())
186
+ except Exception as exc:
187
+ logger.error(f"MCP discovery failed: {exc}")
188
+ discovered = []
189
+ if discovered:
190
+ logger.info(f"MCP tools registered: {', '.join(discovered)}")
191
+
192
+ # Normalize optional list arguments (Typer defaults are None)
193
+ files = files or []
194
+ messages = messages or []
195
+
196
+ logger.debug(f"Starting tilde with model={model}, provider={provider}, mode={mode}")
197
+
198
+ # Determine execution mode
199
+ if mode == "json":
200
+ # JSON event stream mode
201
+ from tilde.runtime.modes.json_mode import run_json_mode
202
+
203
+ run_json_mode(
204
+ model=model,
205
+ provider=provider,
206
+ messages=messages,
207
+ files=files,
208
+ thinking=thinking,
209
+ accept_edits=accept_edits,
210
+ plan_mode=plan,
211
+ )
212
+ elif mode == "rpc":
213
+ # RPC mode (stdin/stdout)
214
+ from tilde.runtime.modes.rpc_mode import run_rpc_mode
215
+
216
+ run_rpc_mode(
217
+ model=model,
218
+ provider=provider,
219
+ thinking=thinking,
220
+ accept_edits=accept_edits,
221
+ plan_mode=plan,
222
+ )
223
+ elif mode == "print":
224
+ # Print mode (single response, then exit)
225
+ from tilde.runtime.modes.print_mode import run_print_mode
226
+
227
+ run_print_mode(
228
+ model=model,
229
+ provider=provider,
230
+ messages=messages,
231
+ files=files,
232
+ thinking=thinking,
233
+ accept_edits=accept_edits,
234
+ plan_mode=plan,
235
+ verbose=verbose,
236
+ )
237
+ else:
238
+ # Interactive TUI mode (default)
239
+ from tilde.runtime.modes.interactive import run_interactive_mode
240
+
241
+ run_interactive_mode(
242
+ model=model,
243
+ provider=provider,
244
+ session=session,
245
+ continue_session=continue_session,
246
+ no_session=no_session,
247
+ context_files=context_files,
248
+ tui_mode=tui_mode,
249
+ thinking=thinking,
250
+ files=files,
251
+ messages=messages,
252
+ accept_edits=accept_edits,
253
+ plan_mode=plan,
254
+ )
255
+
256
+
257
+ def version_callback(value: bool) -> None:
258
+ """Callback for version flag."""
259
+ if value:
260
+ from tilde import __version__
261
+
262
+ typer.echo(f"tilde version {__version__}")
263
+ raise typer.Exit()
264
+
265
+
266
+ # Add version flag
267
+ @app.callback()
268
+ def common(
269
+ version: Annotated[
270
+ bool,
271
+ typer.Option(
272
+ "--version",
273
+ callback=version_callback,
274
+ is_eager=True,
275
+ help="Show version and exit",
276
+ ),
277
+ ] = False,
278
+ ) -> None:
279
+ pass
280
+
281
+
282
+ if __name__ == "__main__":
283
+ app()
tilde/logging.py ADDED
@@ -0,0 +1,74 @@
1
+ """
2
+ Central logging setup for tilde.
3
+
4
+ One function to configure logging for the whole app.
5
+ Uses structlog so every log line carries the same fields
6
+ (name, level, timestamp) in plain text or JSON.
7
+
8
+ Usage:
9
+ from tilde.logging import configure_logging, get_logger
10
+
11
+ configure_logging(verbose=True) # once, at startup (cli.py does this)
12
+ log = get_logger(__name__) # in any module
13
+ log.info("agent started", model="gpt-4")
14
+ """
15
+
16
+ import logging
17
+
18
+ import structlog
19
+
20
+ _configured = False
21
+
22
+
23
+ def configure_logging(verbose: bool = False, json_format: bool = False) -> None:
24
+ """
25
+ Set up logging once at startup. Safe to call more than once.
26
+
27
+ Args:
28
+ verbose: True for DEBUG output, False for INFO.
29
+ json_format: True for machine-readable JSON lines (for
30
+ the `json` / `rpc` runtime modes and log collectors).
31
+ """
32
+ global _configured
33
+
34
+ level = logging.DEBUG if verbose else logging.INFO
35
+ logging.basicConfig(level=level)
36
+ logging.getLogger().setLevel(level)
37
+
38
+ renderer = (
39
+ structlog.processors.JSONRenderer()
40
+ if json_format
41
+ else structlog.dev.ConsoleRenderer(colors=False)
42
+ )
43
+
44
+ structlog.configure(
45
+ processors=[
46
+ structlog.contextvars.merge_contextvars,
47
+ structlog.stdlib.add_log_level,
48
+ structlog.stdlib.PositionalArgumentsFormatter(),
49
+ structlog.processors.TimeStamper(fmt="iso"),
50
+ structlog.processors.StackInfoRenderer(),
51
+ structlog.processors.format_exc_info,
52
+ structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
53
+ ],
54
+ logger_factory=structlog.stdlib.LoggerFactory(),
55
+ wrapper_class=structlog.stdlib.BoundLogger,
56
+ cache_logger_on_first_use=True,
57
+ )
58
+
59
+ formatter = structlog.stdlib.ProcessorFormatter(
60
+ processor=renderer,
61
+ foreign_pre_chain=[
62
+ structlog.stdlib.add_log_level,
63
+ structlog.processors.TimeStamper(fmt="iso"),
64
+ ],
65
+ )
66
+ handler = logging.getLogger().handlers[0]
67
+ handler.setFormatter(formatter)
68
+
69
+ _configured = True
70
+
71
+
72
+ def get_logger(name: str) -> structlog.stdlib.BoundLogger:
73
+ """Return a logger for the given module name."""
74
+ return structlog.get_logger(name)
tilde/main.py ADDED
@@ -0,0 +1,29 @@
1
+ """
2
+ tilde main entry point.
3
+
4
+ This module provides the main entry point for the tilde CLI application.
5
+ It initializes the application and handles the command-line interface.
6
+ """
7
+
8
+ from tilde.cli import app
9
+
10
+
11
+ def main() -> None:
12
+ """
13
+ Main entry point for the tilde command.
14
+
15
+ This function is called when the user runs `tilde` from the command line.
16
+ It initializes the CLI application and executes it.
17
+
18
+ The CLI application handles:
19
+ - Command-line argument parsing
20
+ - Application initialization
21
+ - TUI or non-interactive mode selection
22
+ - Error handling and exit codes
23
+ """
24
+ # Execute the CLI application
25
+ app()
26
+
27
+
28
+ if __name__ == "__main__":
29
+ main()
@@ -0,0 +1,16 @@
1
+ """
2
+ Agent Runtime package.
3
+
4
+ This package contains the core agent runtime components:
5
+ - agent: Main agent class and runtime
6
+ - model: LLM provider integrations
7
+ - tools: Built-in tool implementations
8
+ - session: Session management and compaction
9
+ - queue: Message queue for steering/follow-up
10
+ - context: Context file loading
11
+ - policy: Security policies and permissions
12
+ """
13
+
14
+ from tilde.runtime.agent import AgentRuntime
15
+
16
+ __all__ = ["AgentRuntime"]
@@ -0,0 +1,261 @@
1
+ """Streaming response parsing helpers (split from agent.py).
2
+
3
+ Zero behavior change: moved verbatim. Imports only stdlib +
4
+ logging + tool types, never agent.py (no circular import).
5
+ """
6
+
7
+ import json
8
+ import re
9
+ import uuid
10
+ from typing import Any
11
+
12
+ from tilde.logging import get_logger
13
+ from tilde.runtime.tools.base import ToolCall
14
+
15
+ log = get_logger(__name__)
16
+
17
+
18
+ def _as_tool_call(tool_call: ToolCall | dict) -> ToolCall:
19
+ """
20
+ Normalize a tool call to a ToolCall.
21
+
22
+ Providers and the streaming parser produce plain dicts;
23
+ internal code uses ToolCall objects. Accept either.
24
+ Model arguments arrive as JSON strings; tools need dicts.
25
+ """
26
+ if isinstance(tool_call, ToolCall):
27
+ if isinstance(tool_call.arguments, str):
28
+ tool_call = ToolCall(
29
+ id=tool_call.id,
30
+ name=tool_call.name,
31
+ arguments=_parse_arguments(tool_call.arguments),
32
+ )
33
+ return tool_call
34
+ raw_arguments = tool_call.get("arguments", {})
35
+ return ToolCall(
36
+ id=tool_call.get("id", ""),
37
+ name=tool_call.get("name", ""),
38
+ arguments=_parse_arguments(raw_arguments),
39
+ )
40
+
41
+
42
+ def _parse_arguments(raw: Any) -> dict:
43
+ """Parse JSON-string arguments to a dict; pass dicts through."""
44
+ if isinstance(raw, dict):
45
+ return raw
46
+ if isinstance(raw, str) and raw.strip():
47
+ try:
48
+ parsed = json.loads(raw)
49
+ return parsed if isinstance(parsed, dict) else {}
50
+ except (json.JSONDecodeError, ValueError):
51
+ return {}
52
+ return {}
53
+
54
+
55
+ class StreamingResponseParser:
56
+ """
57
+ Parser for streaming model responses that may contain tool calls.
58
+
59
+ This class buffers streaming chunks and attempts to parse tool calls
60
+ from the response as it arrives. It handles:
61
+ - Buffering partial JSON responses
62
+ - Detecting complete tool call objects
63
+ - Parsing tool calls from various model response formats
64
+ - Error recovery for malformed JSON
65
+ """
66
+
67
+ # Maximum buffer size to prevent memory issues
68
+ MAX_BUFFER_SIZE = 100000 # 100KB
69
+
70
+ def __init__(self) -> None:
71
+ """Initialize the parser."""
72
+ self._buffer = ""
73
+ self._tool_calls: list[dict] = []
74
+ self._complete = False
75
+ self._current_chunk_type = None # 'text' or 'tool_call'
76
+ self._error: str | None = None
77
+
78
+ def add_chunk(self, chunk: str) -> list[dict]:
79
+ """
80
+ Add a chunk of response text and parse any tool calls.
81
+
82
+ Args:
83
+ chunk: Chunk of text from the streaming response
84
+
85
+ Returns:
86
+ List of parsed tool calls (if any)
87
+ """
88
+ if not chunk:
89
+ return []
90
+
91
+ # Truncate buffer if it gets too large
92
+ self._buffer += chunk
93
+ if len(self._buffer) > self.MAX_BUFFER_SIZE:
94
+ # Keep the last 50KB to avoid losing context
95
+ self._buffer = self._buffer[-50000:]
96
+
97
+ # Try to parse tool calls from the buffer
98
+ try:
99
+ new_tool_calls = self._try_parse_tool_calls()
100
+ if new_tool_calls:
101
+ self._tool_calls.extend(new_tool_calls)
102
+ return new_tool_calls
103
+ except Exception as e:
104
+ # Log the error but don't fail
105
+ self._error = f"Parse error: {e!s}"
106
+ return []
107
+
108
+ def _try_parse_tool_calls(self) -> list[dict]:
109
+ """
110
+ Try to parse tool calls from the buffered content.
111
+
112
+ This handles different model response formats:
113
+ - OpenAI: tool_calls in message
114
+ - Anthropic: tool_use blocks
115
+ - Generic: JSON with tool_calls field
116
+
117
+ Returns:
118
+ List of parsed tool calls
119
+ """
120
+ tool_calls = []
121
+
122
+ # Try to find complete JSON objects in the buffer
123
+ # Look for patterns like: {"tool_calls": [...]}
124
+ # Or Anthropic format: {"type": "tool_use", ...}
125
+
126
+ # Pattern 1: OpenAI/Generic format - look for tool_calls
127
+ tool_call_pattern = r'"tool_calls"\s*:\s*\[([^\]]*)\]'
128
+ match = re.search(tool_call_pattern, self._buffer)
129
+ if match:
130
+ try:
131
+ # Extract the tool_calls array
132
+ tool_calls_str = match.group(1)
133
+ # Try to parse it as JSON
134
+ if tool_calls_str.strip():
135
+ # This is a simplified parser - in production, use a proper JSON parser
136
+ # that can handle partial JSON
137
+ tool_calls = self._parse_tool_calls_array(tool_calls_str)
138
+ except Exception:
139
+ # Partial chunks routinely fail to parse; debug only
140
+ log.debug("tool_calls_parse_retry")
141
+
142
+ # Pattern 2: Anthropic format - look for tool_use
143
+ tool_use_pattern = r'"type"\s*:\s*"tool_use"[^}]+\}'
144
+ matches = re.findall(tool_use_pattern, self._buffer)
145
+ for match in matches:
146
+ try:
147
+ tool_call = self._parse_anthropic_tool_call(match)
148
+ if tool_call:
149
+ tool_calls.append(tool_call)
150
+ except Exception:
151
+ log.debug("anthropic_tool_parse_retry")
152
+
153
+ return tool_calls
154
+
155
+ def _parse_tool_calls_array(self, array_str: str) -> list[dict]:
156
+ """
157
+ Parse a tool_calls JSON array string.
158
+
159
+ Args:
160
+ array_str: String containing a JSON array of tool calls
161
+
162
+ Returns:
163
+ List of parsed tool call dicts
164
+ """
165
+ # Simple parsing - in production, use a streaming JSON parser
166
+ tool_calls = []
167
+
168
+ # Try to parse as JSON
169
+ try:
170
+ import json
171
+
172
+ parsed = json.loads(f"[{array_str}]")
173
+ if isinstance(parsed, list):
174
+ for item in parsed:
175
+ if isinstance(item, dict):
176
+ tool_calls.append(self._normalize_tool_call(item))
177
+ except Exception:
178
+ # If parsing fails, try to extract individual tool calls
179
+ # This is a fallback for partial JSON
180
+ log.debug("tool_array_parse_retry")
181
+
182
+ return tool_calls
183
+
184
+ def _parse_anthropic_tool_call(self, tool_use_str: str) -> dict | None:
185
+ """
186
+ Parse an Anthropic tool_use block.
187
+
188
+ Args:
189
+ tool_use_str: String containing a tool_use JSON object
190
+
191
+ Returns:
192
+ Parsed tool call dict or None
193
+ """
194
+ try:
195
+ import json
196
+
197
+ # Add braces if needed
198
+ if not tool_use_str.startswith("{"):
199
+ tool_use_str = "{" + tool_use_str
200
+ if not tool_use_str.endswith("}"):
201
+ tool_use_str = tool_use_str + "}"
202
+
203
+ parsed = json.loads(tool_use_str)
204
+ return self._normalize_tool_call(parsed)
205
+ except Exception:
206
+ return None
207
+
208
+ def _normalize_tool_call(self, tool_call: dict) -> dict:
209
+ """
210
+ Normalize a tool call dict to our internal format.
211
+
212
+ Args:
213
+ tool_call: Raw tool call dict from model
214
+
215
+ Returns:
216
+ Normalized tool call dict
217
+ """
218
+ # Handle OpenAI format
219
+ if "function" in tool_call:
220
+ return {
221
+ "id": tool_call.get("id", str(uuid.uuid4())),
222
+ "name": tool_call["function"].get("name", ""),
223
+ "arguments": tool_call["function"].get("arguments", {}),
224
+ }
225
+
226
+ # Handle Anthropic format
227
+ if tool_call.get("type") == "tool_use":
228
+ return {
229
+ "id": tool_call.get("id", str(uuid.uuid4())),
230
+ "name": tool_call.get("name", ""),
231
+ "arguments": tool_call.get("input", {}),
232
+ }
233
+
234
+ # Handle generic format
235
+ return {
236
+ "id": tool_call.get("id", str(uuid.uuid4())),
237
+ "name": tool_call.get("name", ""),
238
+ "arguments": tool_call.get("arguments", {}),
239
+ }
240
+
241
+ def get_tool_calls(self) -> list[dict]:
242
+ """
243
+ Get all parsed tool calls.
244
+
245
+ Returns:
246
+ List of all tool calls found so far
247
+ """
248
+ return self._tool_calls.copy()
249
+
250
+ def is_complete(self) -> bool:
251
+ """
252
+ Check if the response is complete.
253
+
254
+ Returns:
255
+ True if the response is complete
256
+ """
257
+ return self._complete
258
+
259
+ def mark_complete(self) -> None:
260
+ """Mark the response as complete."""
261
+ self._complete = True