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.
Files changed (67) hide show
  1. mita/__init__.py +5 -0
  2. mita/__main__.py +5 -0
  3. mita/agent/__init__.py +1 -0
  4. mita/agent/context.py +43 -0
  5. mita/agent/conversation.py +101 -0
  6. mita/agent/loop.py +594 -0
  7. mita/agent/system_prompt.py +75 -0
  8. mita/cli.py +940 -0
  9. mita/config/__init__.py +6 -0
  10. mita/config/defaults.py +41 -0
  11. mita/config/loader.py +53 -0
  12. mita/config/schema.py +131 -0
  13. mita/hooks/__init__.py +1 -0
  14. mita/hooks/manager.py +94 -0
  15. mita/hooks/runner.py +145 -0
  16. mita/index/__init__.py +1 -0
  17. mita/index/embeddings.py +49 -0
  18. mita/index/manager.py +170 -0
  19. mita/index/parser.py +331 -0
  20. mita/index/retriever.py +53 -0
  21. mita/index/store.py +143 -0
  22. mita/llm/__init__.py +1 -0
  23. mita/llm/client.py +86 -0
  24. mita/llm/instructor.py +80 -0
  25. mita/llm/streaming.py +58 -0
  26. mita/memory/__init__.py +6 -0
  27. mita/memory/discovery.py +61 -0
  28. mita/memory/loader.py +76 -0
  29. mita/memory/manager.py +117 -0
  30. mita/models/__init__.py +13 -0
  31. mita/models/hardware.py +289 -0
  32. mita/models/manager.py +268 -0
  33. mita/models/ollama_client.py +104 -0
  34. mita/models/recommender.py +88 -0
  35. mita/models/registry.py +167 -0
  36. mita/models/server.py +262 -0
  37. mita/plugins/__init__.py +1 -0
  38. mita/plugins/client.py +152 -0
  39. mita/plugins/manager.py +210 -0
  40. mita/py.typed +0 -0
  41. mita/skills/__init__.py +1 -0
  42. mita/skills/executor.py +84 -0
  43. mita/skills/loader.py +117 -0
  44. mita/skills/manager.py +129 -0
  45. mita/tools/__init__.py +1 -0
  46. mita/tools/builtins/__init__.py +28 -0
  47. mita/tools/builtins/file_edit.py +71 -0
  48. mita/tools/builtins/file_read.py +74 -0
  49. mita/tools/builtins/file_write.py +42 -0
  50. mita/tools/builtins/git.py +112 -0
  51. mita/tools/builtins/glob_tool.py +67 -0
  52. mita/tools/builtins/grep_tool.py +93 -0
  53. mita/tools/builtins/shell.py +83 -0
  54. mita/tools/executor.py +80 -0
  55. mita/tools/registry.py +69 -0
  56. mita/tools/safety.py +91 -0
  57. mita/tools/schema.py +87 -0
  58. mita/ui/__init__.py +1 -0
  59. mita/ui/display.py +139 -0
  60. mita/ui/repl.py +88 -0
  61. mita/ui/spinner.py +48 -0
  62. mita/ui/theme.py +23 -0
  63. mita_code-0.1.0.dist-info/METADATA +227 -0
  64. mita_code-0.1.0.dist-info/RECORD +67 -0
  65. mita_code-0.1.0.dist-info/WHEEL +4 -0
  66. mita_code-0.1.0.dist-info/entry_points.txt +3 -0
  67. 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"