mita-code 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.
- mita/__init__.py +5 -0
- mita/__main__.py +5 -0
- mita/agent/__init__.py +1 -0
- mita/agent/context.py +43 -0
- mita/agent/conversation.py +101 -0
- mita/agent/loop.py +594 -0
- mita/agent/system_prompt.py +75 -0
- mita/cli.py +940 -0
- mita/config/__init__.py +6 -0
- mita/config/defaults.py +41 -0
- mita/config/loader.py +53 -0
- mita/config/schema.py +131 -0
- mita/hooks/__init__.py +1 -0
- mita/hooks/manager.py +94 -0
- mita/hooks/runner.py +145 -0
- mita/index/__init__.py +1 -0
- mita/index/embeddings.py +49 -0
- mita/index/manager.py +170 -0
- mita/index/parser.py +331 -0
- mita/index/retriever.py +53 -0
- mita/index/store.py +143 -0
- mita/llm/__init__.py +1 -0
- mita/llm/client.py +86 -0
- mita/llm/instructor.py +80 -0
- mita/llm/streaming.py +58 -0
- mita/memory/__init__.py +6 -0
- mita/memory/discovery.py +61 -0
- mita/memory/loader.py +76 -0
- mita/memory/manager.py +117 -0
- mita/models/__init__.py +13 -0
- mita/models/hardware.py +289 -0
- mita/models/manager.py +268 -0
- mita/models/ollama_client.py +104 -0
- mita/models/recommender.py +88 -0
- mita/models/registry.py +167 -0
- mita/models/server.py +262 -0
- mita/plugins/__init__.py +1 -0
- mita/plugins/client.py +152 -0
- mita/plugins/manager.py +210 -0
- mita/py.typed +0 -0
- mita/skills/__init__.py +1 -0
- mita/skills/executor.py +84 -0
- mita/skills/loader.py +117 -0
- mita/skills/manager.py +129 -0
- mita/tools/__init__.py +1 -0
- mita/tools/builtins/__init__.py +28 -0
- mita/tools/builtins/file_edit.py +71 -0
- mita/tools/builtins/file_read.py +74 -0
- mita/tools/builtins/file_write.py +42 -0
- mita/tools/builtins/git.py +112 -0
- mita/tools/builtins/glob_tool.py +67 -0
- mita/tools/builtins/grep_tool.py +93 -0
- mita/tools/builtins/shell.py +83 -0
- mita/tools/executor.py +80 -0
- mita/tools/registry.py +69 -0
- mita/tools/safety.py +91 -0
- mita/tools/schema.py +87 -0
- mita/ui/__init__.py +1 -0
- mita/ui/display.py +139 -0
- mita/ui/repl.py +88 -0
- mita/ui/spinner.py +48 -0
- mita/ui/theme.py +23 -0
- mita_code-0.1.0.dist-info/METADATA +227 -0
- mita_code-0.1.0.dist-info/RECORD +67 -0
- mita_code-0.1.0.dist-info/WHEEL +4 -0
- mita_code-0.1.0.dist-info/entry_points.txt +3 -0
- mita_code-0.1.0.dist-info/licenses/LICENSE +201 -0
mita/agent/loop.py
ADDED
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
"""Core async agent loop: prompt → LLM → parse tool calls → execute → loop."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
from mita.agent.context import assemble_context
|
|
14
|
+
from mita.agent.conversation import Conversation, Message, Role
|
|
15
|
+
from mita.config.schema import MitaConfig
|
|
16
|
+
from mita.llm.client import LLMClient
|
|
17
|
+
from mita.tools.executor import execute_tool
|
|
18
|
+
from mita.tools.registry import ToolRegistry, create_default_registry
|
|
19
|
+
from mita.tools.schema import ToolCall
|
|
20
|
+
from mita.ui.display import (
|
|
21
|
+
display_error,
|
|
22
|
+
display_markdown,
|
|
23
|
+
display_response_stats,
|
|
24
|
+
display_streaming_end,
|
|
25
|
+
display_streaming_token,
|
|
26
|
+
display_tool_call,
|
|
27
|
+
display_tool_result,
|
|
28
|
+
prompt_user_confirm,
|
|
29
|
+
)
|
|
30
|
+
from mita.ui.spinner import thinking_spinner
|
|
31
|
+
|
|
32
|
+
MAX_ITERATIONS = 25
|
|
33
|
+
_RAG_CONTEXT_PREFIX = "Relevant code from the project index:"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def run_agent(
|
|
37
|
+
user_prompt: str,
|
|
38
|
+
config: MitaConfig,
|
|
39
|
+
console: Console,
|
|
40
|
+
conversation: Conversation | None = None,
|
|
41
|
+
registry: ToolRegistry | None = None,
|
|
42
|
+
llm_client: LLMClient | None = None,
|
|
43
|
+
) -> Conversation:
|
|
44
|
+
"""Run the agent loop for a single user prompt.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
user_prompt: The user's input.
|
|
48
|
+
config: Application configuration.
|
|
49
|
+
console: Rich console for output.
|
|
50
|
+
conversation: Existing conversation to continue, or None to start fresh.
|
|
51
|
+
registry: Tool registry, or None to create default.
|
|
52
|
+
llm_client: LLM client, or None to create from config.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
The updated conversation.
|
|
56
|
+
"""
|
|
57
|
+
# Initialize
|
|
58
|
+
if registry is None:
|
|
59
|
+
registry = create_default_registry()
|
|
60
|
+
|
|
61
|
+
if llm_client is None:
|
|
62
|
+
llm_client = LLMClient(config)
|
|
63
|
+
if conversation is None:
|
|
64
|
+
conversation = Conversation()
|
|
65
|
+
assemble_context(conversation, config, registry)
|
|
66
|
+
|
|
67
|
+
# Add user message
|
|
68
|
+
conversation.add(Message(role=Role.USER, content=user_prompt))
|
|
69
|
+
|
|
70
|
+
# Inject RAG context if index is available (replace previous RAG message)
|
|
71
|
+
if config.index.enabled:
|
|
72
|
+
try:
|
|
73
|
+
from mita.index.retriever import Retriever
|
|
74
|
+
|
|
75
|
+
retriever = Retriever(config)
|
|
76
|
+
if retriever.is_available():
|
|
77
|
+
rag_context = await retriever.retrieve_formatted(user_prompt)
|
|
78
|
+
if rag_context:
|
|
79
|
+
# Remove any previous RAG context message
|
|
80
|
+
conversation.messages = [
|
|
81
|
+
m
|
|
82
|
+
for m in conversation.messages
|
|
83
|
+
if not (m.role == Role.SYSTEM and m.content.startswith(_RAG_CONTEXT_PREFIX))
|
|
84
|
+
]
|
|
85
|
+
conversation.add(
|
|
86
|
+
Message(
|
|
87
|
+
role=Role.SYSTEM,
|
|
88
|
+
content=f"{_RAG_CONTEXT_PREFIX}\n{rag_context}",
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
except (ConnectionError, FileNotFoundError, ImportError, OSError):
|
|
92
|
+
pass # Index unavailable; proceed without RAG
|
|
93
|
+
|
|
94
|
+
# Fire session_start hooks
|
|
95
|
+
if config.hooks:
|
|
96
|
+
from mita.hooks.runner import run_hooks
|
|
97
|
+
|
|
98
|
+
await run_hooks("session_start", config.hooks, console=console)
|
|
99
|
+
|
|
100
|
+
# Agent loop
|
|
101
|
+
last_tool_signature: str | None = None
|
|
102
|
+
repeat_count = 0
|
|
103
|
+
for iteration in range(MAX_ITERATIONS):
|
|
104
|
+
try:
|
|
105
|
+
# Truncate to fit context window
|
|
106
|
+
conversation.truncate_to_fit(config.model.context_window)
|
|
107
|
+
|
|
108
|
+
# Call LLM with tool schemas so the model can produce structured tool calls
|
|
109
|
+
messages = conversation.get_messages_for_api()
|
|
110
|
+
tool_schemas = registry.get_openai_schemas()
|
|
111
|
+
|
|
112
|
+
if config.ui.stream:
|
|
113
|
+
assistant_text, tool_calls_raw, stats = await _stream_response(
|
|
114
|
+
llm_client, messages, tool_schemas, console
|
|
115
|
+
)
|
|
116
|
+
if config.ui.show_token_count:
|
|
117
|
+
display_response_stats(
|
|
118
|
+
console,
|
|
119
|
+
prompt_tokens=stats.prompt_tokens,
|
|
120
|
+
completion_tokens=stats.completion_tokens,
|
|
121
|
+
total_time=stats.total_time,
|
|
122
|
+
ttft=stats.ttft,
|
|
123
|
+
)
|
|
124
|
+
else:
|
|
125
|
+
t0 = time.monotonic()
|
|
126
|
+
with thinking_spinner(console):
|
|
127
|
+
response = await llm_client.chat(messages, tools=tool_schemas)
|
|
128
|
+
elapsed = time.monotonic() - t0
|
|
129
|
+
assistant_text, tool_calls_raw = _parse_response(response)
|
|
130
|
+
if assistant_text:
|
|
131
|
+
display_markdown(console, assistant_text)
|
|
132
|
+
if config.ui.show_token_count:
|
|
133
|
+
usage = _extract_usage(response) or {}
|
|
134
|
+
display_response_stats(
|
|
135
|
+
console,
|
|
136
|
+
prompt_tokens=usage.get("prompt_tokens", 0),
|
|
137
|
+
completion_tokens=usage.get("completion_tokens", 0),
|
|
138
|
+
total_time=elapsed,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# Fallback: parse tool calls from text if model didn't use native calling
|
|
142
|
+
if not tool_calls_raw and assistant_text:
|
|
143
|
+
parsed, remaining_text = _extract_tool_calls_from_text(assistant_text, registry)
|
|
144
|
+
if parsed:
|
|
145
|
+
tool_calls_raw = parsed
|
|
146
|
+
assistant_text = remaining_text
|
|
147
|
+
|
|
148
|
+
# Handle tool calls
|
|
149
|
+
if tool_calls_raw:
|
|
150
|
+
# Detect repeated identical tool calls (model stuck in a loop)
|
|
151
|
+
sig = json.dumps(
|
|
152
|
+
[
|
|
153
|
+
(
|
|
154
|
+
tc.get("function", {}).get("name"),
|
|
155
|
+
tc.get("function", {}).get("arguments"),
|
|
156
|
+
)
|
|
157
|
+
for tc in tool_calls_raw
|
|
158
|
+
],
|
|
159
|
+
sort_keys=True,
|
|
160
|
+
)
|
|
161
|
+
if sig == last_tool_signature:
|
|
162
|
+
repeat_count += 1
|
|
163
|
+
if repeat_count >= 1:
|
|
164
|
+
display_error(
|
|
165
|
+
console,
|
|
166
|
+
"Detected repeated tool call — stopping to avoid infinite loop.",
|
|
167
|
+
)
|
|
168
|
+
conversation.add(Message(role=Role.ASSISTANT, content=assistant_text))
|
|
169
|
+
break
|
|
170
|
+
else:
|
|
171
|
+
repeat_count = 0
|
|
172
|
+
last_tool_signature = sig
|
|
173
|
+
|
|
174
|
+
conversation.add(
|
|
175
|
+
Message(
|
|
176
|
+
role=Role.ASSISTANT,
|
|
177
|
+
content=assistant_text,
|
|
178
|
+
tool_calls=tool_calls_raw,
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
await _process_tool_calls(tool_calls_raw, conversation, registry, config, console)
|
|
182
|
+
continue
|
|
183
|
+
|
|
184
|
+
# No tool calls — add assistant message and stop
|
|
185
|
+
conversation.add(Message(role=Role.ASSISTANT, content=assistant_text))
|
|
186
|
+
break
|
|
187
|
+
|
|
188
|
+
except KeyboardInterrupt:
|
|
189
|
+
display_error(console, "[Interrupted]")
|
|
190
|
+
# Add any partial response as assistant message
|
|
191
|
+
partial = locals().get("assistant_text", "")
|
|
192
|
+
if partial:
|
|
193
|
+
conversation.add(Message(role=Role.ASSISTANT, content=str(partial)))
|
|
194
|
+
break
|
|
195
|
+
else:
|
|
196
|
+
display_error(
|
|
197
|
+
console,
|
|
198
|
+
f"Reached maximum iterations ({MAX_ITERATIONS}). Stopping.",
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
# Fire session_end hooks
|
|
202
|
+
if config.hooks:
|
|
203
|
+
from mita.hooks.runner import run_hooks
|
|
204
|
+
|
|
205
|
+
await run_hooks("session_end", config.hooks, console=console)
|
|
206
|
+
|
|
207
|
+
return conversation
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class _ResponseStats:
|
|
211
|
+
"""Token usage and timing stats from an LLM response."""
|
|
212
|
+
|
|
213
|
+
prompt_tokens: int = 0
|
|
214
|
+
completion_tokens: int = 0
|
|
215
|
+
total_time: float = 0.0
|
|
216
|
+
ttft: float | None = None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
async def _stream_response(
|
|
220
|
+
client: LLMClient,
|
|
221
|
+
messages: list[dict[str, Any]],
|
|
222
|
+
tool_schemas: list[dict[str, Any]],
|
|
223
|
+
console: Console,
|
|
224
|
+
) -> tuple[str, list[dict[str, Any]], _ResponseStats]:
|
|
225
|
+
"""Stream the LLM response, displaying tokens as they arrive.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
Tuple of (text_content, tool_calls_raw, stats).
|
|
229
|
+
"""
|
|
230
|
+
full_text = ""
|
|
231
|
+
tool_calls_by_index: dict[int, dict[str, Any]] = {}
|
|
232
|
+
first_token = True
|
|
233
|
+
stats = _ResponseStats()
|
|
234
|
+
start_time = time.monotonic()
|
|
235
|
+
|
|
236
|
+
# Show spinner while waiting for first token
|
|
237
|
+
spinner_ctx = thinking_spinner(console)
|
|
238
|
+
spinner_ctx.__enter__()
|
|
239
|
+
|
|
240
|
+
async for chunk in client.stream_chat(messages, tools=tool_schemas):
|
|
241
|
+
if first_token:
|
|
242
|
+
spinner_ctx.__exit__(None, None, None)
|
|
243
|
+
stats.ttft = time.monotonic() - start_time
|
|
244
|
+
first_token = False
|
|
245
|
+
|
|
246
|
+
delta = _extract_delta(chunk)
|
|
247
|
+
if delta:
|
|
248
|
+
full_text += delta
|
|
249
|
+
display_streaming_token(console, delta)
|
|
250
|
+
|
|
251
|
+
# Accumulate tool call deltas
|
|
252
|
+
_accumulate_tool_call_deltas(chunk, tool_calls_by_index)
|
|
253
|
+
|
|
254
|
+
# Extract usage from final chunk (LiteLLM includes it on the last chunk)
|
|
255
|
+
usage = _extract_usage(chunk)
|
|
256
|
+
if usage:
|
|
257
|
+
stats.prompt_tokens = usage.get("prompt_tokens", 0)
|
|
258
|
+
stats.completion_tokens = usage.get("completion_tokens", 0)
|
|
259
|
+
|
|
260
|
+
# Clean up spinner if no chunks arrived at all
|
|
261
|
+
if first_token:
|
|
262
|
+
spinner_ctx.__exit__(None, None, None)
|
|
263
|
+
|
|
264
|
+
stats.total_time = time.monotonic() - start_time
|
|
265
|
+
|
|
266
|
+
if full_text:
|
|
267
|
+
display_streaming_end(console)
|
|
268
|
+
|
|
269
|
+
# Convert accumulated tool calls to list
|
|
270
|
+
tool_calls_raw = [tool_calls_by_index[i] for i in sorted(tool_calls_by_index)]
|
|
271
|
+
return full_text, tool_calls_raw, stats
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _extract_usage(chunk: Any) -> dict[str, int] | None:
|
|
275
|
+
"""Extract token usage from a streaming chunk (typically the last one)."""
|
|
276
|
+
try:
|
|
277
|
+
usage = getattr(chunk, "usage", None) or (
|
|
278
|
+
chunk.get("usage") if isinstance(chunk, dict) else None
|
|
279
|
+
)
|
|
280
|
+
if usage is None:
|
|
281
|
+
return None
|
|
282
|
+
if hasattr(usage, "prompt_tokens"):
|
|
283
|
+
return {
|
|
284
|
+
"prompt_tokens": usage.prompt_tokens or 0,
|
|
285
|
+
"completion_tokens": usage.completion_tokens or 0,
|
|
286
|
+
}
|
|
287
|
+
if isinstance(usage, dict) and "prompt_tokens" in usage:
|
|
288
|
+
return usage
|
|
289
|
+
except (AttributeError, KeyError):
|
|
290
|
+
pass
|
|
291
|
+
return None
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _extract_delta(chunk: Any) -> str:
|
|
295
|
+
"""Extract text content from a streaming chunk."""
|
|
296
|
+
try:
|
|
297
|
+
choices = chunk.choices if hasattr(chunk, "choices") else chunk.get("choices", [])
|
|
298
|
+
if not choices:
|
|
299
|
+
return ""
|
|
300
|
+
delta = choices[0].delta if hasattr(choices[0], "delta") else choices[0].get("delta", {})
|
|
301
|
+
if hasattr(delta, "content"):
|
|
302
|
+
return delta.content or ""
|
|
303
|
+
if isinstance(delta, dict):
|
|
304
|
+
return delta.get("content", "") or ""
|
|
305
|
+
except (IndexError, AttributeError, KeyError):
|
|
306
|
+
pass
|
|
307
|
+
return ""
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _accumulate_tool_call_deltas(
|
|
311
|
+
chunk: Any, tool_calls_by_index: dict[int, dict[str, Any]]
|
|
312
|
+
) -> None:
|
|
313
|
+
"""Accumulate tool call deltas from a streaming chunk.
|
|
314
|
+
|
|
315
|
+
Streaming tool calls arrive as incremental deltas indexed by position.
|
|
316
|
+
This function merges them into complete tool call dicts.
|
|
317
|
+
"""
|
|
318
|
+
try:
|
|
319
|
+
choices = chunk.choices if hasattr(chunk, "choices") else chunk.get("choices", [])
|
|
320
|
+
if not choices:
|
|
321
|
+
return
|
|
322
|
+
delta = choices[0].delta if hasattr(choices[0], "delta") else choices[0].get("delta", {})
|
|
323
|
+
|
|
324
|
+
raw_tcs = (
|
|
325
|
+
delta.tool_calls
|
|
326
|
+
if hasattr(delta, "tool_calls")
|
|
327
|
+
else delta.get("tool_calls")
|
|
328
|
+
if isinstance(delta, dict)
|
|
329
|
+
else None
|
|
330
|
+
)
|
|
331
|
+
if not raw_tcs:
|
|
332
|
+
return
|
|
333
|
+
|
|
334
|
+
for tc in raw_tcs:
|
|
335
|
+
idx = getattr(tc, "index", None) if hasattr(tc, "index") else tc.get("index", 0)
|
|
336
|
+
if idx is None:
|
|
337
|
+
idx = 0
|
|
338
|
+
|
|
339
|
+
if idx not in tool_calls_by_index:
|
|
340
|
+
tc_id = (getattr(tc, "id", "") if hasattr(tc, "id") else tc.get("id", "")) or str(
|
|
341
|
+
uuid.uuid4()
|
|
342
|
+
)
|
|
343
|
+
tool_calls_by_index[idx] = {
|
|
344
|
+
"id": tc_id,
|
|
345
|
+
"type": "function",
|
|
346
|
+
"function": {"name": "", "arguments": ""},
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
entry = tool_calls_by_index[idx]
|
|
350
|
+
func = getattr(tc, "function", None) if hasattr(tc, "function") else tc.get("function")
|
|
351
|
+
|
|
352
|
+
if func is not None:
|
|
353
|
+
fname = getattr(func, "name", None) if hasattr(func, "name") else func.get("name")
|
|
354
|
+
if fname:
|
|
355
|
+
entry["function"]["name"] += fname
|
|
356
|
+
|
|
357
|
+
fargs = (
|
|
358
|
+
getattr(func, "arguments", None)
|
|
359
|
+
if hasattr(func, "arguments")
|
|
360
|
+
else func.get("arguments")
|
|
361
|
+
)
|
|
362
|
+
if fargs:
|
|
363
|
+
entry["function"]["arguments"] += fargs
|
|
364
|
+
except (IndexError, AttributeError, KeyError):
|
|
365
|
+
pass
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _parse_response(response: Any) -> tuple[str, list[dict[str, Any]]]:
|
|
369
|
+
"""Parse a non-streaming LLM response into text and tool calls."""
|
|
370
|
+
try:
|
|
371
|
+
choices = response.choices if hasattr(response, "choices") else response.get("choices", [])
|
|
372
|
+
if not choices:
|
|
373
|
+
return "", []
|
|
374
|
+
|
|
375
|
+
message = (
|
|
376
|
+
choices[0].message if hasattr(choices[0], "message") else choices[0].get("message", {})
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
content = ""
|
|
380
|
+
if hasattr(message, "content"):
|
|
381
|
+
content = message.content or ""
|
|
382
|
+
elif isinstance(message, dict):
|
|
383
|
+
content = message.get("content", "") or ""
|
|
384
|
+
|
|
385
|
+
tool_calls: list[dict[str, Any]] = []
|
|
386
|
+
raw_calls = (
|
|
387
|
+
message.tool_calls
|
|
388
|
+
if hasattr(message, "tool_calls")
|
|
389
|
+
else message.get("tool_calls")
|
|
390
|
+
if isinstance(message, dict)
|
|
391
|
+
else None
|
|
392
|
+
)
|
|
393
|
+
if raw_calls:
|
|
394
|
+
for tc in raw_calls:
|
|
395
|
+
if hasattr(tc, "function"):
|
|
396
|
+
tool_calls.append(
|
|
397
|
+
{
|
|
398
|
+
"id": getattr(tc, "id", str(uuid.uuid4())),
|
|
399
|
+
"type": "function",
|
|
400
|
+
"function": {
|
|
401
|
+
"name": tc.function.name,
|
|
402
|
+
"arguments": tc.function.arguments,
|
|
403
|
+
},
|
|
404
|
+
}
|
|
405
|
+
)
|
|
406
|
+
elif isinstance(tc, dict):
|
|
407
|
+
tool_calls.append(tc)
|
|
408
|
+
|
|
409
|
+
return content, tool_calls
|
|
410
|
+
except (IndexError, AttributeError, KeyError):
|
|
411
|
+
return "", []
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _extract_tool_calls_from_text(
|
|
415
|
+
text: str, registry: ToolRegistry
|
|
416
|
+
) -> tuple[list[dict[str, Any]], str]:
|
|
417
|
+
"""Extract tool calls from text when the model outputs JSON instead of native calls.
|
|
418
|
+
|
|
419
|
+
Some local models output tool call JSON in text content rather than using
|
|
420
|
+
the structured tool_calls field. This parses those and returns them as
|
|
421
|
+
proper tool call dicts along with any remaining non-tool text.
|
|
422
|
+
|
|
423
|
+
Returns:
|
|
424
|
+
Tuple of (tool_calls_raw, remaining_text).
|
|
425
|
+
"""
|
|
426
|
+
tool_calls: list[dict[str, Any]] = []
|
|
427
|
+
remaining_parts: list[str] = []
|
|
428
|
+
|
|
429
|
+
# First strip markdown fences, then find bare JSON objects
|
|
430
|
+
# Replace fenced JSON blocks with their contents for uniform parsing
|
|
431
|
+
fenced = re.compile(r"```(?:json)?\s*(\{.*?\})\s*```", re.DOTALL)
|
|
432
|
+
normalized = fenced.sub(r"\1", text)
|
|
433
|
+
|
|
434
|
+
# Find JSON objects by scanning for top-level braces
|
|
435
|
+
json_spans: list[tuple[int, int, dict[str, Any]]] = []
|
|
436
|
+
i = 0
|
|
437
|
+
while i < len(normalized):
|
|
438
|
+
if normalized[i] == "{":
|
|
439
|
+
obj, end = _try_parse_json_object(normalized, i)
|
|
440
|
+
if obj is not None:
|
|
441
|
+
json_spans.append((i, end, obj))
|
|
442
|
+
i = end
|
|
443
|
+
continue
|
|
444
|
+
i += 1
|
|
445
|
+
|
|
446
|
+
last_end = 0
|
|
447
|
+
for start, end, obj in json_spans:
|
|
448
|
+
before = normalized[last_end:start].strip()
|
|
449
|
+
if before:
|
|
450
|
+
remaining_parts.append(before)
|
|
451
|
+
last_end = end
|
|
452
|
+
|
|
453
|
+
name = obj.get("name", "")
|
|
454
|
+
arguments = obj.get("arguments", obj.get("params", {}))
|
|
455
|
+
if name and registry.has_tool(name) and isinstance(arguments, dict):
|
|
456
|
+
tool_calls.append(
|
|
457
|
+
{
|
|
458
|
+
"id": str(uuid.uuid4()),
|
|
459
|
+
"type": "function",
|
|
460
|
+
"function": {
|
|
461
|
+
"name": name,
|
|
462
|
+
"arguments": json.dumps(arguments),
|
|
463
|
+
},
|
|
464
|
+
}
|
|
465
|
+
)
|
|
466
|
+
else:
|
|
467
|
+
remaining_parts.append(normalized[start:end])
|
|
468
|
+
|
|
469
|
+
trailing = normalized[last_end:].strip()
|
|
470
|
+
if trailing:
|
|
471
|
+
remaining_parts.append(trailing)
|
|
472
|
+
|
|
473
|
+
remaining_text = "\n".join(remaining_parts).strip()
|
|
474
|
+
return tool_calls, remaining_text
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _try_parse_json_object(text: str, start: int) -> tuple[dict[str, Any] | None, int]:
|
|
478
|
+
"""Try to parse a JSON object starting at position start in text.
|
|
479
|
+
|
|
480
|
+
Returns (parsed_dict, end_position) or (None, start) if parsing fails.
|
|
481
|
+
"""
|
|
482
|
+
depth = 0
|
|
483
|
+
in_string = False
|
|
484
|
+
escape = False
|
|
485
|
+
for i in range(start, len(text)):
|
|
486
|
+
ch = text[i]
|
|
487
|
+
if escape:
|
|
488
|
+
escape = False
|
|
489
|
+
continue
|
|
490
|
+
if ch == "\\":
|
|
491
|
+
escape = True
|
|
492
|
+
continue
|
|
493
|
+
if ch == '"' and not escape:
|
|
494
|
+
in_string = not in_string
|
|
495
|
+
continue
|
|
496
|
+
if in_string:
|
|
497
|
+
continue
|
|
498
|
+
if ch == "{":
|
|
499
|
+
depth += 1
|
|
500
|
+
elif ch == "}":
|
|
501
|
+
depth -= 1
|
|
502
|
+
if depth == 0:
|
|
503
|
+
candidate = text[start : i + 1]
|
|
504
|
+
try:
|
|
505
|
+
obj = json.loads(candidate)
|
|
506
|
+
if isinstance(obj, dict):
|
|
507
|
+
return obj, i + 1
|
|
508
|
+
except json.JSONDecodeError:
|
|
509
|
+
return None, start
|
|
510
|
+
return None, start
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
async def _process_tool_calls(
|
|
514
|
+
tool_calls_raw: list[dict[str, Any]],
|
|
515
|
+
conversation: Conversation,
|
|
516
|
+
registry: ToolRegistry,
|
|
517
|
+
config: MitaConfig,
|
|
518
|
+
console: Console,
|
|
519
|
+
) -> None:
|
|
520
|
+
"""Process tool calls from an LLM response."""
|
|
521
|
+
for tc_raw in tool_calls_raw:
|
|
522
|
+
func = tc_raw.get("function", {})
|
|
523
|
+
tc_id = tc_raw.get("id", str(uuid.uuid4()))
|
|
524
|
+
name = func.get("name", "") if isinstance(func, dict) else ""
|
|
525
|
+
args_raw = func.get("arguments", "{}") if isinstance(func, dict) else "{}"
|
|
526
|
+
|
|
527
|
+
# Parse arguments
|
|
528
|
+
if isinstance(args_raw, str):
|
|
529
|
+
try:
|
|
530
|
+
arguments = json.loads(args_raw)
|
|
531
|
+
except json.JSONDecodeError:
|
|
532
|
+
arguments = {}
|
|
533
|
+
else:
|
|
534
|
+
arguments = args_raw if isinstance(args_raw, dict) else {}
|
|
535
|
+
|
|
536
|
+
tool_call = ToolCall(id=tc_id, name=name, arguments=arguments)
|
|
537
|
+
|
|
538
|
+
# Display the tool call
|
|
539
|
+
display_tool_call(console, tool_call)
|
|
540
|
+
|
|
541
|
+
# Fire pre_tool_call hooks
|
|
542
|
+
if config.hooks:
|
|
543
|
+
from mita.hooks.runner import run_hooks
|
|
544
|
+
|
|
545
|
+
await run_hooks(
|
|
546
|
+
"pre_tool_call",
|
|
547
|
+
config.hooks,
|
|
548
|
+
context={"tool": name, "args": arguments},
|
|
549
|
+
console=console,
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
# Create confirm function bound to console
|
|
553
|
+
async def confirm_fn(prompt: str) -> bool:
|
|
554
|
+
return await prompt_user_confirm(console, prompt)
|
|
555
|
+
|
|
556
|
+
# Execute with safety checks
|
|
557
|
+
result = await execute_tool(tool_call, registry, config.tools, confirm_fn=confirm_fn)
|
|
558
|
+
|
|
559
|
+
# Display result
|
|
560
|
+
display_tool_result(console, result)
|
|
561
|
+
|
|
562
|
+
# Fire post_tool_call hooks
|
|
563
|
+
if config.hooks:
|
|
564
|
+
from mita.hooks.runner import run_hooks
|
|
565
|
+
|
|
566
|
+
await run_hooks(
|
|
567
|
+
"post_tool_call",
|
|
568
|
+
config.hooks,
|
|
569
|
+
context={"tool": name, "result": str(result.output or result.error)},
|
|
570
|
+
console=console,
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
# Fire on_file_write hooks for file_write/file_edit tools
|
|
574
|
+
if config.hooks and result.success and name in ("file_write", "file_edit"):
|
|
575
|
+
from mita.hooks.runner import run_hooks
|
|
576
|
+
|
|
577
|
+
file_path = arguments.get("path", "")
|
|
578
|
+
if file_path:
|
|
579
|
+
await run_hooks(
|
|
580
|
+
"on_file_write",
|
|
581
|
+
config.hooks,
|
|
582
|
+
context={"file_path": file_path},
|
|
583
|
+
console=console,
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
# Add tool result to conversation
|
|
587
|
+
conversation.add(
|
|
588
|
+
Message(
|
|
589
|
+
role=Role.TOOL,
|
|
590
|
+
content=result.output if result.success else (result.error or "Error"),
|
|
591
|
+
tool_call_id=tc_id,
|
|
592
|
+
name=name,
|
|
593
|
+
)
|
|
594
|
+
)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Build the system prompt from config, memory, and tool definitions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from mita.config.schema import MitaConfig
|
|
6
|
+
from mita.tools.schema import ToolDefinition
|
|
7
|
+
|
|
8
|
+
SYSTEM_PROMPT_TEMPLATE = """\
|
|
9
|
+
You are Mita, a local-first coding assistant running on the user's machine.
|
|
10
|
+
You help with software engineering tasks: writing code, debugging, refactoring, \
|
|
11
|
+
explaining code, and answering questions.
|
|
12
|
+
|
|
13
|
+
You have access to the following tools to interact with the local filesystem and \
|
|
14
|
+
run commands. Use them to help the user.
|
|
15
|
+
|
|
16
|
+
## Available Tools
|
|
17
|
+
{tools_section}
|
|
18
|
+
|
|
19
|
+
## How to Use Tools
|
|
20
|
+
You MUST use tools to take actions. Do NOT just describe what to do — actually do it.
|
|
21
|
+
When asked to create a file, use file_write. When asked to run code, use shell.
|
|
22
|
+
When asked to do multiple things, call each tool in sequence.
|
|
23
|
+
After a tool succeeds, move on to the next step. Do NOT repeat a tool call that already succeeded.
|
|
24
|
+
|
|
25
|
+
## Guidelines
|
|
26
|
+
- Read files before modifying them to understand context.
|
|
27
|
+
- Use file_write to create new files — do NOT use shell with echo/cat to create files.
|
|
28
|
+
- Use file_edit for targeted changes to existing files.
|
|
29
|
+
- Use glob and grep to explore the codebase before making changes.
|
|
30
|
+
- Run tests after making changes to verify correctness.
|
|
31
|
+
- Ask for confirmation before destructive operations.
|
|
32
|
+
- Be concise in your responses. Lead with the answer, not the reasoning.
|
|
33
|
+
- When you're done with a task, say so clearly.
|
|
34
|
+
{memory_section}\
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def build_system_prompt(
|
|
39
|
+
config: MitaConfig,
|
|
40
|
+
memory: str,
|
|
41
|
+
tools: list[ToolDefinition],
|
|
42
|
+
) -> str:
|
|
43
|
+
"""Build the full system prompt.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
config: The application configuration.
|
|
47
|
+
memory: Loaded memory content from MITA.md files.
|
|
48
|
+
tools: All available tool definitions.
|
|
49
|
+
"""
|
|
50
|
+
tools_section = _format_tools(tools)
|
|
51
|
+
memory_section = _format_memory(memory)
|
|
52
|
+
return SYSTEM_PROMPT_TEMPLATE.format(
|
|
53
|
+
tools_section=tools_section,
|
|
54
|
+
memory_section=memory_section,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _format_tools(tools: list[ToolDefinition]) -> str:
|
|
59
|
+
"""Format tool definitions for the system prompt."""
|
|
60
|
+
parts: list[str] = []
|
|
61
|
+
for tool in tools:
|
|
62
|
+
params = ", ".join(
|
|
63
|
+
f"{p.name}: {p.type}" + ("" if p.required else f" = {p.default}")
|
|
64
|
+
for p in tool.parameters
|
|
65
|
+
)
|
|
66
|
+
destructive_note = " [DESTRUCTIVE]" if tool.destructive else ""
|
|
67
|
+
parts.append(f"- **{tool.name}**({params}){destructive_note}: {tool.description}")
|
|
68
|
+
return "\n".join(parts)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _format_memory(memory: str) -> str:
|
|
72
|
+
"""Format memory content for the system prompt."""
|
|
73
|
+
if not memory.strip():
|
|
74
|
+
return ""
|
|
75
|
+
return f"\n## Project Memory\n{memory}\n"
|