yunta-harness 1.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- yunta/__init__.py +25 -0
- yunta/agent.py +181 -0
- yunta/api.py +123 -0
- yunta/cli.py +103 -0
- yunta/compact.py +28 -0
- yunta/feedback.py +74 -0
- yunta/mcp.py +147 -0
- yunta/provider.py +233 -0
- yunta/tools/__init__.py +44 -0
- yunta/tools/bash.py +32 -0
- yunta/tools/delegate.py +56 -0
- yunta/tools/files.py +86 -0
- yunta/tools/memory.py +116 -0
- yunta/tools/search.py +74 -0
- yunta_harness-1.0.2.dist-info/METADATA +261 -0
- yunta_harness-1.0.2.dist-info/RECORD +20 -0
- yunta_harness-1.0.2.dist-info/WHEEL +5 -0
- yunta_harness-1.0.2.dist-info/entry_points.txt +2 -0
- yunta_harness-1.0.2.dist-info/licenses/LICENSE +21 -0
- yunta_harness-1.0.2.dist-info/top_level.txt +1 -0
yunta/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from .agent import Agent
|
|
2
|
+
from .api import Block, BlockType, Message, Response, Role, StopReason, ToolDef, Usage
|
|
3
|
+
from .compact import NoCompaction, SlidingWindow
|
|
4
|
+
from .provider import LiteLLMProvider
|
|
5
|
+
from .feedback import FeedbackStore
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
# Núcleo (estable desde v0.1)
|
|
9
|
+
"Agent",
|
|
10
|
+
"Block",
|
|
11
|
+
"BlockType",
|
|
12
|
+
"Message",
|
|
13
|
+
"Response",
|
|
14
|
+
"Role",
|
|
15
|
+
"StopReason",
|
|
16
|
+
"ToolDef",
|
|
17
|
+
"Usage",
|
|
18
|
+
# Compactación (estable desde v0.1)
|
|
19
|
+
"NoCompaction",
|
|
20
|
+
"SlidingWindow",
|
|
21
|
+
# Provider (estable desde v0.1; send/on_text desde v0.7)
|
|
22
|
+
"LiteLLMProvider",
|
|
23
|
+
# Auto-feedback (estable desde v0.3)
|
|
24
|
+
"FeedbackStore",
|
|
25
|
+
]
|
yunta/agent.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import difflib
|
|
2
|
+
import inspect
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .api import Block, BlockType, Message, Response, Role, StopReason, Usage
|
|
7
|
+
from .tools import registry
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Agent:
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
provider,
|
|
14
|
+
system: str,
|
|
15
|
+
compactor=None,
|
|
16
|
+
max_turns: int = 30,
|
|
17
|
+
confirm=None,
|
|
18
|
+
tools: list | None = None,
|
|
19
|
+
):
|
|
20
|
+
self.provider = provider
|
|
21
|
+
self.system = system
|
|
22
|
+
self.compactor = compactor
|
|
23
|
+
self.max_turns = max_turns
|
|
24
|
+
self.confirm = confirm
|
|
25
|
+
self.messages: list[Message] = []
|
|
26
|
+
self._tools_subset = list(tools) if tools is not None else None
|
|
27
|
+
self.usage = Usage()
|
|
28
|
+
|
|
29
|
+
def send(self, prompt: str) -> str:
|
|
30
|
+
self.usage.turns += 1
|
|
31
|
+
self.messages.append(
|
|
32
|
+
Message(role=Role.USER, content=[Block(type=BlockType.TEXT, text=prompt)])
|
|
33
|
+
)
|
|
34
|
+
return self._loop()
|
|
35
|
+
|
|
36
|
+
def _definitions(self) -> list:
|
|
37
|
+
if self._tools_subset is None:
|
|
38
|
+
return registry.definitions()
|
|
39
|
+
return [
|
|
40
|
+
t for t in registry.definitions() if t.name in {tool.name for tool in self._tools_subset}
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
def _loop(self) -> str:
|
|
44
|
+
final_text = []
|
|
45
|
+
try:
|
|
46
|
+
for _ in range(self.max_turns):
|
|
47
|
+
if self.compactor:
|
|
48
|
+
self.messages = self.compactor.compact(self.messages)
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
supports_stream = (
|
|
52
|
+
"on_text" in inspect.signature(self.provider.send).parameters
|
|
53
|
+
)
|
|
54
|
+
except (TypeError, ValueError):
|
|
55
|
+
supports_stream = False
|
|
56
|
+
self._streamed = False
|
|
57
|
+
if supports_stream:
|
|
58
|
+
resp = self.provider.send(self.messages, self._definitions(), on_text=self._stream_text)
|
|
59
|
+
else:
|
|
60
|
+
resp = self.provider.send(self.messages, self._definitions())
|
|
61
|
+
if not hasattr(self.provider, "total_usage") and getattr(resp, "usage", None):
|
|
62
|
+
self.usage = self.usage.add(resp.usage)
|
|
63
|
+
self.messages.append(Message(role=Role.ASSISTANT, content=resp.content))
|
|
64
|
+
|
|
65
|
+
tool_results = []
|
|
66
|
+
has_tool_call = False
|
|
67
|
+
for b in resp.content:
|
|
68
|
+
if b.type == BlockType.TEXT and b.text:
|
|
69
|
+
if self._streamed:
|
|
70
|
+
print()
|
|
71
|
+
else:
|
|
72
|
+
print(b.text)
|
|
73
|
+
final_text.append(b.text)
|
|
74
|
+
elif b.type == BlockType.TOOL_USE:
|
|
75
|
+
has_tool_call = True
|
|
76
|
+
result, is_err = self._execute_tool(b.tool_name, b.tool_input)
|
|
77
|
+
tool_results.append(
|
|
78
|
+
Block(
|
|
79
|
+
type=BlockType.TOOL_RESULT,
|
|
80
|
+
tool_use_id=b.tool_use_id,
|
|
81
|
+
tool_result=result,
|
|
82
|
+
is_error=is_err,
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if resp.stop_reason != StopReason.TOOL_USE or not has_tool_call:
|
|
87
|
+
return "\n".join(final_text).strip()
|
|
88
|
+
|
|
89
|
+
self.messages.append(Message(role=Role.USER, content=tool_results))
|
|
90
|
+
except KeyboardInterrupt:
|
|
91
|
+
print("\n(interrumpido por el usuario)")
|
|
92
|
+
if self.messages and self.messages[-1].role == Role.USER:
|
|
93
|
+
self.messages.append(
|
|
94
|
+
Message(
|
|
95
|
+
role=Role.ASSISTANT,
|
|
96
|
+
content=[Block(type=BlockType.TEXT, text="[interrumpido por el usuario]")],
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
return "\n".join(final_text).strip() or "[interrumpido por el usuario]"
|
|
100
|
+
|
|
101
|
+
return "\n".join(final_text).strip()
|
|
102
|
+
|
|
103
|
+
def _stream_text(self, delta: str) -> None:
|
|
104
|
+
print(delta, end="", flush=True)
|
|
105
|
+
self._streamed = True
|
|
106
|
+
|
|
107
|
+
def _execute_tool(self, name: str, raw_input: str) -> tuple[str, bool]:
|
|
108
|
+
self.usage.tool_counts[name] = self.usage.tool_counts.get(name, 0) + 1
|
|
109
|
+
tool = registry.get(name)
|
|
110
|
+
if tool is None:
|
|
111
|
+
self.usage.tool_errors += 1
|
|
112
|
+
return f"unknown tool: {name}", True
|
|
113
|
+
|
|
114
|
+
detail = self._tool_detail(name, raw_input)
|
|
115
|
+
print(f"[tool] {name} {raw_input}")
|
|
116
|
+
if tool.requires_approval and not self._approve(name, detail):
|
|
117
|
+
self.usage.tool_errors += 1
|
|
118
|
+
return "user denied this tool call", True
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
res = tool.fn(raw_input)
|
|
122
|
+
return res, False
|
|
123
|
+
except Exception as e:
|
|
124
|
+
self.usage.tool_errors += 1
|
|
125
|
+
return f"{type(e).__name__}: {e}", True
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def _tool_detail(name: str, raw_input: str) -> str:
|
|
129
|
+
if name not in ("write_file", "str_replace"):
|
|
130
|
+
return ""
|
|
131
|
+
try:
|
|
132
|
+
args = json.loads(raw_input or "{}")
|
|
133
|
+
except json.JSONDecodeError:
|
|
134
|
+
return ""
|
|
135
|
+
|
|
136
|
+
path = args.get("path", "")
|
|
137
|
+
if not path:
|
|
138
|
+
return ""
|
|
139
|
+
|
|
140
|
+
p = Path(path)
|
|
141
|
+
old = p.read_text(encoding="utf-8", errors="replace") if p.exists() else ""
|
|
142
|
+
|
|
143
|
+
if name == "write_file":
|
|
144
|
+
new_content = args.get("content", "")
|
|
145
|
+
elif name == "str_replace":
|
|
146
|
+
old_str = args.get("old_str", "")
|
|
147
|
+
new_str = args.get("new_str", "")
|
|
148
|
+
if not old_str or old.count(old_str) != 1:
|
|
149
|
+
return ""
|
|
150
|
+
new_content = old.replace(old_str, new_str, 1)
|
|
151
|
+
|
|
152
|
+
diff = difflib.unified_diff(
|
|
153
|
+
old.splitlines(keepends=True),
|
|
154
|
+
new_content.splitlines(keepends=True),
|
|
155
|
+
fromfile=f"a/{path}",
|
|
156
|
+
tofile=f"b/{path}",
|
|
157
|
+
)
|
|
158
|
+
return "".join(diff)
|
|
159
|
+
|
|
160
|
+
def _approve(self, name: str, detail: str) -> bool:
|
|
161
|
+
if self.confirm is not None:
|
|
162
|
+
return self.confirm(name, detail)
|
|
163
|
+
if detail:
|
|
164
|
+
print(detail)
|
|
165
|
+
answer = input(f"¿Ejecutar {name}? [y/N] ").strip().lower()
|
|
166
|
+
return answer == "y"
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def total_usage(self) -> Usage:
|
|
170
|
+
p_usage = getattr(self.provider, "total_usage", None)
|
|
171
|
+
in_tok = p_usage.input_tokens if p_usage else self.usage.input_tokens
|
|
172
|
+
out_tok = p_usage.output_tokens if p_usage else self.usage.output_tokens
|
|
173
|
+
cached_tok = p_usage.cached_tokens if p_usage else self.usage.cached_tokens
|
|
174
|
+
return Usage(
|
|
175
|
+
input_tokens=in_tok,
|
|
176
|
+
output_tokens=out_tok,
|
|
177
|
+
cached_tokens=cached_tok,
|
|
178
|
+
tool_counts=dict(self.usage.tool_counts),
|
|
179
|
+
tool_errors=self.usage.tool_errors,
|
|
180
|
+
turns=self.usage.turns,
|
|
181
|
+
)
|
yunta/api.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from enum import Enum
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Role(str, Enum):
|
|
6
|
+
USER = "user"
|
|
7
|
+
ASSISTANT = "assistant"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BlockType(str, Enum):
|
|
11
|
+
TEXT = "text"
|
|
12
|
+
TOOL_USE = "tool_use"
|
|
13
|
+
TOOL_RESULT = "tool_result"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class StopReason(str, Enum):
|
|
17
|
+
END_TURN = "end_turn"
|
|
18
|
+
TOOL_USE = "tool_use"
|
|
19
|
+
OTHER = "other"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Block:
|
|
24
|
+
type: BlockType
|
|
25
|
+
text: str = ""
|
|
26
|
+
tool_use_id: str = ""
|
|
27
|
+
tool_name: str = ""
|
|
28
|
+
tool_input: str = ""
|
|
29
|
+
tool_result: str = ""
|
|
30
|
+
is_error: bool = False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Message:
|
|
35
|
+
role: Role
|
|
36
|
+
content: list[Block] = field(default_factory=list)
|
|
37
|
+
|
|
38
|
+
def has_tool_result(self) -> bool:
|
|
39
|
+
return any(b.type == BlockType.TOOL_RESULT for b in self.content)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ToolDef:
|
|
44
|
+
name: str
|
|
45
|
+
description: str
|
|
46
|
+
parameters: dict
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Usage:
|
|
51
|
+
input_tokens: int = 0
|
|
52
|
+
output_tokens: int = 0
|
|
53
|
+
cached_tokens: int = 0
|
|
54
|
+
tool_counts: dict[str, int] = field(default_factory=dict)
|
|
55
|
+
tool_errors: int = 0
|
|
56
|
+
turns: int = 0
|
|
57
|
+
|
|
58
|
+
def add(self, other: "Usage") -> "Usage":
|
|
59
|
+
merged_tools = dict(self.tool_counts)
|
|
60
|
+
for k, v in other.tool_counts.items():
|
|
61
|
+
merged_tools[k] = merged_tools.get(k, 0) + v
|
|
62
|
+
return Usage(
|
|
63
|
+
input_tokens=self.input_tokens + other.input_tokens,
|
|
64
|
+
output_tokens=self.output_tokens + other.output_tokens,
|
|
65
|
+
cached_tokens=self.cached_tokens + other.cached_tokens,
|
|
66
|
+
tool_counts=merged_tools,
|
|
67
|
+
tool_errors=self.tool_errors + other.tool_errors,
|
|
68
|
+
turns=self.turns + other.turns,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def cache_rate(self) -> float:
|
|
73
|
+
"""Porcentaje de tokens de entrada que vinieron de caché."""
|
|
74
|
+
total_in = self.input_tokens + self.cached_tokens
|
|
75
|
+
return (self.cached_tokens / total_in * 100.0) if total_in else 0.0
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def theoretical_raw_tokens(self) -> int:
|
|
79
|
+
"""Tokens de entrada brutos que habrías transferido en un chat crudo sin caching."""
|
|
80
|
+
return self.input_tokens + self.cached_tokens
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def total_tool_calls(self) -> int:
|
|
84
|
+
"""Total acumulado de llamadas a herramientas."""
|
|
85
|
+
return sum(self.tool_counts.values())
|
|
86
|
+
|
|
87
|
+
def format_summary(self) -> str:
|
|
88
|
+
"""Formatea un resumen legible y comparativo en 2-3 líneas limpias."""
|
|
89
|
+
total_in = self.theoretical_raw_tokens
|
|
90
|
+
cached_info = f" (⚡ {self.cache_rate:.1f}% ahorro de caché)" if self.cached_tokens else ""
|
|
91
|
+
lines = [
|
|
92
|
+
f"Tokens: entrada={self.input_tokens:,} | cacheados={self.cached_tokens:,}{cached_info} | salida={self.output_tokens:,}",
|
|
93
|
+
f"Sin Harness habrías transferido: {total_in:,} tokens de entrada",
|
|
94
|
+
]
|
|
95
|
+
if self.tool_counts:
|
|
96
|
+
breakdown = ", ".join(f"{k}: {v}" for k, v in sorted(self.tool_counts.items()))
|
|
97
|
+
err_info = f" (errores/rechazos: {self.tool_errors})" if self.tool_errors else ""
|
|
98
|
+
lines.append(f"Herramientas ({self.total_tool_calls} llamadas): {breakdown}{err_info}")
|
|
99
|
+
if self.turns:
|
|
100
|
+
lines.append(f"Turnos de interacción: {self.turns}")
|
|
101
|
+
return "\n".join(lines)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class Response:
|
|
106
|
+
content: list[Block] = field(default_factory=list)
|
|
107
|
+
stop_reason: StopReason = StopReason.OTHER
|
|
108
|
+
usage: Usage = field(default_factory=Usage)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def render_transcript(messages: list[Message]) -> str:
|
|
112
|
+
lines = []
|
|
113
|
+
for m in messages:
|
|
114
|
+
parts = []
|
|
115
|
+
for b in m.content:
|
|
116
|
+
if b.type == BlockType.TEXT:
|
|
117
|
+
parts.append(b.text)
|
|
118
|
+
elif b.type == BlockType.TOOL_USE:
|
|
119
|
+
parts.append(f"[called {b.tool_name} with {b.tool_input}]")
|
|
120
|
+
else:
|
|
121
|
+
parts.append(f"[tool result: {b.tool_result}]")
|
|
122
|
+
lines.append(f"{m.role.value}: " + "\n".join(parts))
|
|
123
|
+
return "\n".join(lines)
|
yunta/cli.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .agent import Agent
|
|
6
|
+
from .feedback import FeedbackStore
|
|
7
|
+
from .mcp import load_mcp_servers
|
|
8
|
+
from .provider import LiteLLMProvider
|
|
9
|
+
from .tools import bash, delegate, files, memory, search # noqa: F401 — registro vía decoradores
|
|
10
|
+
|
|
11
|
+
SYSTEM_PROMPT = """Eres un ingeniero de software que programa en pareja a través del harness Yunta.
|
|
12
|
+
Trabajas iterando: lees archivos, ejecutas comandos y editas código usando tus tools.
|
|
13
|
+
Sé conciso. Si un tool falla, el error vuelve a tu contexto: ajústalo y reintenta.
|
|
14
|
+
Responde en el idioma del usuario.
|
|
15
|
+
|
|
16
|
+
Frontera de rol y entorno de ejecución:
|
|
17
|
+
- Yunta es tu banco de herramientas en terminal (read_file, str_replace, bash), NO el runtime de la aplicación.
|
|
18
|
+
- Tu objetivo es desarrollar el código del proyecto del usuario en este espacio de trabajo.
|
|
19
|
+
- NUNCA crees servicios, daemons ni plugins que corran "dentro de Yunta". El software desarrollado vivirá en su propio entorno de producción (ej. nube, contenedor, Power Automate, web, CLI propio, etc.).
|
|
20
|
+
|
|
21
|
+
Reglas de honestidad y verificación:
|
|
22
|
+
- NUNCA afirmes haber ejecutado o editado algo sin haberlo hecho con una tool
|
|
23
|
+
real en esta conversación. Narrar acciones imaginarias es un fallo grave.
|
|
24
|
+
- Verifica tu trabajo: tras editar código, ejecuta los tests o el comando
|
|
25
|
+
que demuestre el resultado antes de declararlo resuelto."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_system_prompt(feedback: FeedbackStore | None = None) -> str:
|
|
29
|
+
env_prompt = os.environ.get("YUNTA_SYSTEM_PROMPT")
|
|
30
|
+
user_prompt_file = Path.home() / ".yunta" / "system_prompt.md"
|
|
31
|
+
if env_prompt:
|
|
32
|
+
prompt = env_prompt
|
|
33
|
+
elif user_prompt_file.exists():
|
|
34
|
+
prompt = user_prompt_file.read_text(encoding="utf-8")
|
|
35
|
+
else:
|
|
36
|
+
prompt = SYSTEM_PROMPT
|
|
37
|
+
|
|
38
|
+
agents_md = Path("AGENTS.md")
|
|
39
|
+
if agents_md.exists():
|
|
40
|
+
prompt += "\n\n# Contexto del proyecto (AGENTS.md)\n\n" + agents_md.read_text(
|
|
41
|
+
encoding="utf-8"
|
|
42
|
+
)
|
|
43
|
+
if feedback is not None:
|
|
44
|
+
prompt += feedback.preamble()
|
|
45
|
+
return prompt
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def main():
|
|
49
|
+
if sys.platform == "win32":
|
|
50
|
+
try:
|
|
51
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
52
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
53
|
+
except Exception:
|
|
54
|
+
pass
|
|
55
|
+
feedback = FeedbackStore()
|
|
56
|
+
system = load_system_prompt(feedback)
|
|
57
|
+
provider = LiteLLMProvider(system=system)
|
|
58
|
+
delegate.set_provider(provider)
|
|
59
|
+
mcp_clients = load_mcp_servers()
|
|
60
|
+
agent = Agent(provider=provider, system=system)
|
|
61
|
+
|
|
62
|
+
print(f"yunta — modelo: {provider.model()}")
|
|
63
|
+
print("Escribe tu consulta, /tokens o /metrics para telemetría, /clear para limpiar, /exit para salir.\n")
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
while True:
|
|
67
|
+
try:
|
|
68
|
+
prompt = input("> ").strip()
|
|
69
|
+
except (EOFError, KeyboardInterrupt):
|
|
70
|
+
print()
|
|
71
|
+
break
|
|
72
|
+
|
|
73
|
+
if not prompt:
|
|
74
|
+
continue
|
|
75
|
+
if prompt == "/exit":
|
|
76
|
+
if agent.messages:
|
|
77
|
+
feedback.summarize(provider, agent.messages)
|
|
78
|
+
break
|
|
79
|
+
if prompt == "/clear":
|
|
80
|
+
agent.messages.clear()
|
|
81
|
+
print("(historial limpio)\n")
|
|
82
|
+
continue
|
|
83
|
+
if prompt in ("/tokens", "/metrics"):
|
|
84
|
+
u = agent.total_usage
|
|
85
|
+
print(u.format_summary() + "\n")
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
agent.send(prompt)
|
|
90
|
+
except KeyboardInterrupt:
|
|
91
|
+
print()
|
|
92
|
+
except SystemExit:
|
|
93
|
+
raise
|
|
94
|
+
except Exception as e:
|
|
95
|
+
print(f"error: {e}", file=sys.stderr)
|
|
96
|
+
print()
|
|
97
|
+
finally:
|
|
98
|
+
for c in mcp_clients:
|
|
99
|
+
c.close()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
if __name__ == "__main__":
|
|
103
|
+
main()
|
yunta/compact.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from .api import BlockType, Message, Role
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class NoCompaction:
|
|
5
|
+
def compact(self, messages: list[Message]) -> list[Message]:
|
|
6
|
+
return messages
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SlidingWindow:
|
|
10
|
+
"""Descarta los mensajes más viejos, cortando solo en límites seguros:
|
|
11
|
+
justo antes de un mensaje user que no contiene tool_results."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, max_messages: int = 40):
|
|
14
|
+
self.max_messages = max_messages
|
|
15
|
+
|
|
16
|
+
def compact(self, messages: list[Message]) -> list[Message]:
|
|
17
|
+
if len(messages) <= self.max_messages:
|
|
18
|
+
return messages
|
|
19
|
+
excess = len(messages) - self.max_messages
|
|
20
|
+
# límite seguro: el índice del primer mensaje user (sin tool_results)
|
|
21
|
+
# que esté en o después del exceso; nunca deja un assistant o
|
|
22
|
+
# tool_result huérfano al inicio de lo que queda.
|
|
23
|
+
cut = excess
|
|
24
|
+
for i in range(excess, len(messages)):
|
|
25
|
+
if messages[i].role == Role.USER and not messages[i].has_tool_result():
|
|
26
|
+
cut = i
|
|
27
|
+
break
|
|
28
|
+
return messages[cut:]
|
yunta/feedback.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from datetime import date
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from .api import Block, BlockType, Message, Role
|
|
5
|
+
|
|
6
|
+
LESSON_PROMPT = """Auto-evalúa la sesión que termina. Responde EXACTAMENTE en tres líneas:
|
|
7
|
+
|
|
8
|
+
TAREA: <la tarea principal, en una frase>
|
|
9
|
+
RESULTADO: <logrado / parcial / fallido, con una palabra de por qué>
|
|
10
|
+
LECCION: <una regla concreta y accionable para futuras sesiones; si todo
|
|
11
|
+
salió bien, una práctica que ayudó>
|
|
12
|
+
|
|
13
|
+
Sin más texto."""
|
|
14
|
+
|
|
15
|
+
HEADER = "# Lecciones de sesiones anteriores (auto-feedback del harness)\n\n"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FeedbackStore:
|
|
19
|
+
"""Registro persistente de auto-evaluaciones. Las últimas lecciones se
|
|
20
|
+
inyectan en el system prompt al arrancar: el agente aprende de sí mismo."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, path: str = ".yunta/learnings.md"):
|
|
23
|
+
self.path = Path(path)
|
|
24
|
+
|
|
25
|
+
def append(self, task: str, outcome: str, lesson: str) -> None:
|
|
26
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
if not self.path.exists():
|
|
28
|
+
self.path.write_text(HEADER, encoding="utf-8")
|
|
29
|
+
line = f"- [{date.today().isoformat()}] {task} → {outcome}. Lección: {lesson}\n"
|
|
30
|
+
with self.path.open("a", encoding="utf-8") as f:
|
|
31
|
+
f.write(line)
|
|
32
|
+
|
|
33
|
+
def lessons(self, limit: int = 5) -> list[str]:
|
|
34
|
+
if not self.path.exists():
|
|
35
|
+
return []
|
|
36
|
+
lines = [
|
|
37
|
+
l.strip("- \n")
|
|
38
|
+
for l in self.path.read_text(encoding="utf-8").splitlines()
|
|
39
|
+
if l.startswith("- ")
|
|
40
|
+
]
|
|
41
|
+
return lines[-limit:]
|
|
42
|
+
|
|
43
|
+
def preamble(self, limit: int = 5) -> str:
|
|
44
|
+
lessons = self.lessons(limit)
|
|
45
|
+
if not lessons:
|
|
46
|
+
return ""
|
|
47
|
+
body = "\n".join(f"- {l}" for l in lessons)
|
|
48
|
+
return (
|
|
49
|
+
"\n\n# Lecciones de sesiones anteriores (aprendidas por ti mismo)\n\n"
|
|
50
|
+
f"{body}\n"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def summarize(self, provider, messages: list[Message]) -> None:
|
|
54
|
+
"""Pide al modelo la auto-evaluación de la sesión y la persiste.
|
|
55
|
+
Best-effort: cualquier fallo se ignora silenciosamente."""
|
|
56
|
+
try:
|
|
57
|
+
resp = provider.send(
|
|
58
|
+
[Message(role=Role.USER, content=[Block(type=BlockType.TEXT, text=LESSON_PROMPT)])]
|
|
59
|
+
+ messages,
|
|
60
|
+
[],
|
|
61
|
+
)
|
|
62
|
+
text = {b.type: b.text for b in resp.content if b.type == BlockType.TEXT}
|
|
63
|
+
parts = {}
|
|
64
|
+
for line in text.get(BlockType.TEXT, "").splitlines():
|
|
65
|
+
if ":" in line:
|
|
66
|
+
k, v = line.split(":", 1)
|
|
67
|
+
parts[k.strip().upper()] = v.strip()
|
|
68
|
+
task = parts.get("TAREA", "(sin registrar)")
|
|
69
|
+
outcome = parts.get("RESULTADO", "desconocido")
|
|
70
|
+
lesson = parts.get("LECCION", "")
|
|
71
|
+
if lesson:
|
|
72
|
+
self.append(task, outcome, lesson)
|
|
73
|
+
except Exception:
|
|
74
|
+
pass
|
yunta/mcp.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from .tools import Tool, _parse, registry
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MCPClient:
|
|
9
|
+
def __init__(self, name: str, command: str, args: list[str] | None = None, env: dict | None = None):
|
|
10
|
+
self.name = name
|
|
11
|
+
full_env = {**os.environ, **(env or {})}
|
|
12
|
+
cmd = [command] + (args or [])
|
|
13
|
+
self.proc = subprocess.Popen(
|
|
14
|
+
cmd,
|
|
15
|
+
stdin=subprocess.PIPE,
|
|
16
|
+
stdout=subprocess.PIPE,
|
|
17
|
+
stderr=subprocess.PIPE,
|
|
18
|
+
text=True,
|
|
19
|
+
encoding="utf-8",
|
|
20
|
+
errors="replace",
|
|
21
|
+
env=full_env,
|
|
22
|
+
)
|
|
23
|
+
self._req_id = 0
|
|
24
|
+
self._init_server()
|
|
25
|
+
|
|
26
|
+
def _next_id(self) -> int:
|
|
27
|
+
self._req_id += 1
|
|
28
|
+
return self._req_id
|
|
29
|
+
|
|
30
|
+
def _send(self, method: str, params: dict | None = None, is_notification: bool = False) -> dict | None:
|
|
31
|
+
req_id = None if is_notification else self._next_id()
|
|
32
|
+
msg = {"jsonrpc": "2.0", "method": method}
|
|
33
|
+
if req_id is not None:
|
|
34
|
+
msg["id"] = req_id
|
|
35
|
+
if params is not None:
|
|
36
|
+
msg["params"] = params
|
|
37
|
+
line = json.dumps(msg) + "\n"
|
|
38
|
+
if not self.proc.stdin:
|
|
39
|
+
raise RuntimeError(f"MCP server '{self.name}' stdin is closed")
|
|
40
|
+
self.proc.stdin.write(line)
|
|
41
|
+
self.proc.stdin.flush()
|
|
42
|
+
if is_notification:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
while True:
|
|
46
|
+
resp_line = self.proc.stdout.readline()
|
|
47
|
+
if not resp_line:
|
|
48
|
+
err = self.proc.stderr.read() if self.proc.stderr else ""
|
|
49
|
+
raise RuntimeError(f"MCP server '{self.name}' terminated unexpectedly: {err}")
|
|
50
|
+
resp_line = resp_line.strip()
|
|
51
|
+
if not resp_line:
|
|
52
|
+
continue
|
|
53
|
+
try:
|
|
54
|
+
data = json.loads(resp_line)
|
|
55
|
+
if data.get("id") == req_id:
|
|
56
|
+
if "error" in data:
|
|
57
|
+
err_msg = data["error"].get("message", str(data["error"]))
|
|
58
|
+
raise RuntimeError(f"MCP error: {err_msg}")
|
|
59
|
+
return data.get("result", {})
|
|
60
|
+
except json.JSONDecodeError:
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
def _init_server(self):
|
|
64
|
+
self._send(
|
|
65
|
+
"initialize",
|
|
66
|
+
{
|
|
67
|
+
"protocolVersion": "2024-11-05",
|
|
68
|
+
"capabilities": {},
|
|
69
|
+
"clientInfo": {"name": "yunta", "version": "0.8.0"},
|
|
70
|
+
},
|
|
71
|
+
)
|
|
72
|
+
self._send("notifications/initialized", is_notification=True)
|
|
73
|
+
|
|
74
|
+
def list_tools(self) -> list[dict]:
|
|
75
|
+
res = self._send("tools/list", {}) or {}
|
|
76
|
+
return res.get("tools", [])
|
|
77
|
+
|
|
78
|
+
def call_tool(self, tool_name: str, arguments: dict) -> str:
|
|
79
|
+
res = self._send("tools/call", {"name": tool_name, "arguments": arguments}) or {}
|
|
80
|
+
contents = res.get("content", [])
|
|
81
|
+
parts = []
|
|
82
|
+
for c in contents:
|
|
83
|
+
if isinstance(c, dict):
|
|
84
|
+
parts.append(c.get("text", json.dumps(c)))
|
|
85
|
+
else:
|
|
86
|
+
parts.append(str(c))
|
|
87
|
+
return "\n".join(parts) if parts else json.dumps(res)
|
|
88
|
+
|
|
89
|
+
def close(self):
|
|
90
|
+
try:
|
|
91
|
+
if self.proc.stdin:
|
|
92
|
+
self.proc.stdin.close()
|
|
93
|
+
self.proc.terminate()
|
|
94
|
+
self.proc.wait(timeout=2)
|
|
95
|
+
except Exception:
|
|
96
|
+
try:
|
|
97
|
+
self.proc.kill()
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def load_mcp_servers(config_path: str = ".yunta/mcp.json") -> list[MCPClient]:
|
|
103
|
+
path = Path(config_path)
|
|
104
|
+
if not path.exists():
|
|
105
|
+
return []
|
|
106
|
+
try:
|
|
107
|
+
cfg = json.loads(path.read_text(encoding="utf-8"))
|
|
108
|
+
except Exception as e:
|
|
109
|
+
print(f"advertencia: fallo al leer {config_path}: {e}")
|
|
110
|
+
return []
|
|
111
|
+
|
|
112
|
+
servers = cfg.get("mcpServers", {})
|
|
113
|
+
clients = []
|
|
114
|
+
for s_name, s_cfg in servers.items():
|
|
115
|
+
cmd = s_cfg.get("command")
|
|
116
|
+
if not cmd:
|
|
117
|
+
continue
|
|
118
|
+
try:
|
|
119
|
+
client = MCPClient(
|
|
120
|
+
name=s_name,
|
|
121
|
+
command=cmd,
|
|
122
|
+
args=s_cfg.get("args"),
|
|
123
|
+
env=s_cfg.get("env"),
|
|
124
|
+
)
|
|
125
|
+
for t in client.list_tools():
|
|
126
|
+
original_name = t.get("name", "")
|
|
127
|
+
tool_name = f"mcp__{s_name}__{original_name}"
|
|
128
|
+
desc = t.get("description", "")
|
|
129
|
+
schema = t.get("inputSchema", {"type": "object", "properties": {}})
|
|
130
|
+
|
|
131
|
+
def make_handler(c: MCPClient, orig_n: str):
|
|
132
|
+
def handler(raw: str) -> str:
|
|
133
|
+
args = _parse(raw)
|
|
134
|
+
return c.call_tool(orig_n, args)
|
|
135
|
+
return handler
|
|
136
|
+
|
|
137
|
+
registry._tools[tool_name] = Tool(
|
|
138
|
+
name=tool_name,
|
|
139
|
+
description=f"[MCP:{s_name}] {desc}",
|
|
140
|
+
parameters=schema,
|
|
141
|
+
fn=make_handler(client, original_name),
|
|
142
|
+
requires_approval=s_cfg.get("requires_approval", False),
|
|
143
|
+
)
|
|
144
|
+
clients.append(client)
|
|
145
|
+
except Exception as e:
|
|
146
|
+
print(f"advertencia: no se pudo iniciar servidor MCP '{s_name}': {e}")
|
|
147
|
+
return clients
|