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.
- tilde/__init__.py +32 -0
- tilde/cli.py +283 -0
- tilde/logging.py +74 -0
- tilde/main.py +29 -0
- tilde/runtime/__init__.py +16 -0
- tilde/runtime/_streaming.py +261 -0
- tilde/runtime/agent.py +1806 -0
- tilde/runtime/context.py +194 -0
- tilde/runtime/events.py +327 -0
- tilde/runtime/mcp.py +321 -0
- tilde/runtime/model/__init__.py +53 -0
- tilde/runtime/model/anthropic.py +275 -0
- tilde/runtime/model/base.py +178 -0
- tilde/runtime/model/openai.py +284 -0
- tilde/runtime/model/openrouter.py +306 -0
- tilde/runtime/modes/__init__.py +21 -0
- tilde/runtime/modes/interactive.py +88 -0
- tilde/runtime/modes/json_mode.py +120 -0
- tilde/runtime/modes/print_mode.py +133 -0
- tilde/runtime/modes/rpc_mode.py +234 -0
- tilde/runtime/policy.py +586 -0
- tilde/runtime/queue.py +274 -0
- tilde/runtime/sandbox.py +129 -0
- tilde/runtime/session/__init__.py +11 -0
- tilde/runtime/session/compaction.py +83 -0
- tilde/runtime/session/manager.py +616 -0
- tilde/runtime/skills.py +158 -0
- tilde/runtime/tools/__init__.py +112 -0
- tilde/runtime/tools/base.py +187 -0
- tilde/runtime/tools/bash.py +314 -0
- tilde/runtime/tools/browser.py +318 -0
- tilde/runtime/tools/edit.py +127 -0
- tilde/runtime/tools/find.py +107 -0
- tilde/runtime/tools/github.py +160 -0
- tilde/runtime/tools/grep.py +150 -0
- tilde/runtime/tools/ls.py +96 -0
- tilde/runtime/tools/read.py +247 -0
- tilde/runtime/tools/skill.py +90 -0
- tilde/runtime/tools/subagent.py +115 -0
- tilde/runtime/tools/sysinfo.py +99 -0
- tilde/runtime/tools/web_search.py +167 -0
- tilde/runtime/tools/write.py +196 -0
- tilde/state/__init__.py +76 -0
- tilde/state/composer.py +47 -0
- tilde/state/context.py +113 -0
- tilde/state/execution.py +55 -0
- tilde/state/inspector.py +124 -0
- tilde/state/transcript.py +52 -0
- tilde/tracing.py +63 -0
- tilde/tui/__init__.py +19 -0
- tilde/tui/app.py +776 -0
- tilde/tui/components/__init__.py +18 -0
- tilde/tui/components/approval.py +51 -0
- tilde/tui/components/composer.py +348 -0
- tilde/tui/components/footer.py +176 -0
- tilde/tui/components/header.py +154 -0
- tilde/tui/components/inspector.py +142 -0
- tilde/tui/components/transcript.py +330 -0
- tilde/tui/display.py +216 -0
- tilde/tui/themes/__init__.py +5 -0
- tilde/tui/themes/tokens.py +26 -0
- tilde_agent-0.1.1.dist-info/METADATA +375 -0
- tilde_agent-0.1.1.dist-info/RECORD +66 -0
- tilde_agent-0.1.1.dist-info/WHEEL +4 -0
- tilde_agent-0.1.1.dist-info/entry_points.txt +3 -0
- 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
|