yeschef-cli 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.
- yeschef/__init__.py +3 -0
- yeschef/__main__.py +6 -0
- yeschef/agent/__init__.py +6 -0
- yeschef/agent/backends/__init__.py +53 -0
- yeschef/agent/backends/anthropic_compat.py +119 -0
- yeschef/agent/backends/base.py +50 -0
- yeschef/agent/backends/cli.py +99 -0
- yeschef/agent/backends/openai_compat.py +118 -0
- yeschef/agent/config.py +98 -0
- yeschef/agent/detect.py +98 -0
- yeschef/agent/harness.py +1009 -0
- yeschef/cli.py +1073 -0
- yeschef/hub/__init__.py +16 -0
- yeschef/hub/api.py +595 -0
- yeschef/hub/app.py +43 -0
- yeschef/hub/dashboard.html +206 -0
- yeschef/hub/events.py +78 -0
- yeschef/hub/mcp_server.py +941 -0
- yeschef/hub/schema.sql +106 -0
- yeschef/hub/store.py +1621 -0
- yeschef/models.py +431 -0
- yeschef/procs.py +109 -0
- yeschef/replay.py +224 -0
- yeschef/resources/__init__.py +0 -0
- yeschef/resources/agents/__init__.py +0 -0
- yeschef/resources/agents/yeschef-expediter.md +74 -0
- yeschef/resources/skill/SKILL.md +212 -0
- yeschef/resources/skill/__init__.py +0 -0
- yeschef/sdk/__init__.py +9 -0
- yeschef/sdk/client.py +359 -0
- yeschef/settings.py +100 -0
- yeschef/tools/__init__.py +5 -0
- yeschef/tools/executor.py +296 -0
- yeschef_cli-0.1.0.dist-info/METADATA +254 -0
- yeschef_cli-0.1.0.dist-info/RECORD +38 -0
- yeschef_cli-0.1.0.dist-info/WHEEL +4 -0
- yeschef_cli-0.1.0.dist-info/entry_points.txt +3 -0
- yeschef_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
yeschef/__init__.py
ADDED
yeschef/__main__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Model backends for the harness."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .anthropic_compat import AnthropicCompatBackend
|
|
6
|
+
from .base import Backend, ChatResult, ToolCall, ToolResult, Turn
|
|
7
|
+
from .cli import CliBackend
|
|
8
|
+
from .openai_compat import OpenAICompatBackend
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build_backend(config: dict) -> Backend:
|
|
12
|
+
"""Construct a backend from the `[backend]` block of an agent config.
|
|
13
|
+
|
|
14
|
+
`tahoma` is an OpenAI-compatible preset — Tahoma serves /v1/chat/completions, so it
|
|
15
|
+
shares the adapter and only differs in defaults.
|
|
16
|
+
"""
|
|
17
|
+
kind = (config.get("type") or "openai_compat").lower()
|
|
18
|
+
if kind == "cli":
|
|
19
|
+
return CliBackend(
|
|
20
|
+
command=list(config.get("command") or []),
|
|
21
|
+
model=config.get("model", "cli-agent"),
|
|
22
|
+
timeout=float(config.get("timeout_s", 1800.0)),
|
|
23
|
+
env=dict(config.get("env") or {}),
|
|
24
|
+
)
|
|
25
|
+
common = {
|
|
26
|
+
"base_url": config["base_url"],
|
|
27
|
+
"model": config["model"],
|
|
28
|
+
"api_key": config.get("api_key"),
|
|
29
|
+
"timeout": float(config.get("timeout_s", 600.0)),
|
|
30
|
+
"extra_body": config.get("extra_body") or {},
|
|
31
|
+
"extra_headers": config.get("extra_headers") or {},
|
|
32
|
+
}
|
|
33
|
+
if kind == "anthropic_compat":
|
|
34
|
+
return AnthropicCompatBackend(**common)
|
|
35
|
+
if kind in ("openai_compat", "tahoma"):
|
|
36
|
+
backend = OpenAICompatBackend(**common)
|
|
37
|
+
if kind == "tahoma":
|
|
38
|
+
backend.name = "tahoma"
|
|
39
|
+
return backend
|
|
40
|
+
raise ValueError(f"unknown backend type: {kind}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"AnthropicCompatBackend",
|
|
45
|
+
"Backend",
|
|
46
|
+
"CliBackend",
|
|
47
|
+
"ChatResult",
|
|
48
|
+
"OpenAICompatBackend",
|
|
49
|
+
"ToolCall",
|
|
50
|
+
"ToolResult",
|
|
51
|
+
"Turn",
|
|
52
|
+
"build_backend",
|
|
53
|
+
]
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Anthropic Messages API shape — Ollama v0.14+ serves this natively at /v1/messages.
|
|
2
|
+
|
|
3
|
+
Ollama defaults to a 4,096-token context; raise it (num_ctx / OLLAMA_CONTEXT_LENGTH) or
|
|
4
|
+
long task prompts are silently truncated.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .base import ChatResult, ToolCall, Turn
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AnthropicCompatBackend:
|
|
17
|
+
name = "anthropic_compat"
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
base_url: str,
|
|
22
|
+
model: str,
|
|
23
|
+
api_key: str | None = None,
|
|
24
|
+
timeout: float = 600.0,
|
|
25
|
+
extra_body: dict | None = None,
|
|
26
|
+
extra_headers: dict | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
self.base_url = base_url.rstrip("/")
|
|
29
|
+
self.model = model
|
|
30
|
+
self.extra_body = extra_body or {}
|
|
31
|
+
headers = {"Content-Type": "application/json", "anthropic-version": "2023-06-01"}
|
|
32
|
+
if api_key:
|
|
33
|
+
headers["x-api-key"] = api_key
|
|
34
|
+
headers.update(extra_headers or {})
|
|
35
|
+
self._http = httpx.AsyncClient(headers=headers, timeout=timeout)
|
|
36
|
+
|
|
37
|
+
async def close(self) -> None:
|
|
38
|
+
await self._http.aclose()
|
|
39
|
+
|
|
40
|
+
async def chat(
|
|
41
|
+
self,
|
|
42
|
+
system: str,
|
|
43
|
+
turns: list[Turn],
|
|
44
|
+
tools: list[dict] | None = None,
|
|
45
|
+
max_tokens: int = 2048,
|
|
46
|
+
temperature: float = 0.7,
|
|
47
|
+
) -> ChatResult:
|
|
48
|
+
messages: list[dict[str, Any]] = []
|
|
49
|
+
for turn in turns:
|
|
50
|
+
if turn.role == "assistant" and turn.tool_calls:
|
|
51
|
+
content: list[dict] = []
|
|
52
|
+
if turn.content:
|
|
53
|
+
content.append({"type": "text", "text": turn.content})
|
|
54
|
+
content.extend(
|
|
55
|
+
{
|
|
56
|
+
"type": "tool_use",
|
|
57
|
+
"id": call.id,
|
|
58
|
+
"name": call.name,
|
|
59
|
+
"input": call.arguments,
|
|
60
|
+
}
|
|
61
|
+
for call in turn.tool_calls
|
|
62
|
+
)
|
|
63
|
+
messages.append({"role": "assistant", "content": content})
|
|
64
|
+
else:
|
|
65
|
+
messages.append({"role": turn.role, "content": turn.content})
|
|
66
|
+
if turn.tool_results:
|
|
67
|
+
messages.append(
|
|
68
|
+
{
|
|
69
|
+
"role": "user",
|
|
70
|
+
"content": [
|
|
71
|
+
{
|
|
72
|
+
"type": "tool_result",
|
|
73
|
+
"tool_use_id": result.call_id,
|
|
74
|
+
"content": result.content,
|
|
75
|
+
"is_error": result.is_error,
|
|
76
|
+
}
|
|
77
|
+
for result in turn.tool_results
|
|
78
|
+
],
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
payload: dict[str, Any] = {
|
|
83
|
+
"model": self.model,
|
|
84
|
+
"system": system,
|
|
85
|
+
"messages": messages,
|
|
86
|
+
"max_tokens": max_tokens,
|
|
87
|
+
"temperature": temperature,
|
|
88
|
+
**self.extra_body,
|
|
89
|
+
}
|
|
90
|
+
if tools:
|
|
91
|
+
payload["tools"] = [
|
|
92
|
+
{
|
|
93
|
+
"name": tool["name"],
|
|
94
|
+
"description": tool.get("description", ""),
|
|
95
|
+
"input_schema": tool.get("input_schema", {"type": "object"}),
|
|
96
|
+
}
|
|
97
|
+
for tool in tools
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
response = await self._http.post(f"{self.base_url}/v1/messages", json=payload)
|
|
101
|
+
response.raise_for_status()
|
|
102
|
+
data = response.json()
|
|
103
|
+
|
|
104
|
+
text_parts, calls = [], []
|
|
105
|
+
for block in data.get("content") or []:
|
|
106
|
+
if block.get("type") == "text":
|
|
107
|
+
text_parts.append(block.get("text", ""))
|
|
108
|
+
elif block.get("type") == "tool_use":
|
|
109
|
+
calls.append(
|
|
110
|
+
ToolCall(id=block["id"], name=block["name"], arguments=block.get("input") or {})
|
|
111
|
+
)
|
|
112
|
+
usage = data.get("usage") or {}
|
|
113
|
+
return ChatResult(
|
|
114
|
+
text="".join(text_parts),
|
|
115
|
+
tool_calls=calls,
|
|
116
|
+
input_tokens=usage.get("input_tokens", 0),
|
|
117
|
+
output_tokens=usage.get("output_tokens", 0),
|
|
118
|
+
stop_reason=data.get("stop_reason"),
|
|
119
|
+
)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Backend protocol: one normalized chat call over any local model server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
from ...models import ToolCall, ToolResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(slots=True)
|
|
12
|
+
class ChatResult:
|
|
13
|
+
text: str
|
|
14
|
+
tool_calls: list[ToolCall] = field(default_factory=list)
|
|
15
|
+
input_tokens: int = 0
|
|
16
|
+
output_tokens: int = 0
|
|
17
|
+
stop_reason: str | None = None
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def total_tokens(self) -> int:
|
|
21
|
+
return self.input_tokens + self.output_tokens
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True)
|
|
25
|
+
class Turn:
|
|
26
|
+
"""One entry of conversation history in backend-neutral form."""
|
|
27
|
+
|
|
28
|
+
role: str # "user" | "assistant"
|
|
29
|
+
content: str = ""
|
|
30
|
+
tool_calls: list[ToolCall] = field(default_factory=list)
|
|
31
|
+
tool_results: list[ToolResult] = field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Backend(Protocol):
|
|
35
|
+
name: str
|
|
36
|
+
model: str
|
|
37
|
+
|
|
38
|
+
async def chat(
|
|
39
|
+
self,
|
|
40
|
+
system: str,
|
|
41
|
+
turns: list[Turn],
|
|
42
|
+
tools: list[dict] | None = None,
|
|
43
|
+
max_tokens: int = 2048,
|
|
44
|
+
temperature: float = 0.7,
|
|
45
|
+
) -> ChatResult: ...
|
|
46
|
+
|
|
47
|
+
async def close(self) -> None: ...
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
__all__ = ["Backend", "ChatResult", "ToolCall", "ToolResult", "Turn"]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Run a real coding-agent CLI as the worker's engine.
|
|
2
|
+
|
|
3
|
+
The chat backends give a worker one model call plus a bounded tool loop — fine for
|
|
4
|
+
drafts and triage, not for a buildout. This backend hands the whole task to an agent
|
|
5
|
+
CLI (``claude -p``, opencode, aider …) running in the task's workspace, where it brings
|
|
6
|
+
its own tool loop, its own depth, and its own judgment.
|
|
7
|
+
|
|
8
|
+
The flagship configuration points ``claude -p`` at a local model server, so the full
|
|
9
|
+
Claude Code harness runs against your own hardware:
|
|
10
|
+
|
|
11
|
+
[backend]
|
|
12
|
+
type = "cli"
|
|
13
|
+
command = ["claude", "-p", "{prompt}", "--dangerously-skip-permissions"]
|
|
14
|
+
[backend.env]
|
|
15
|
+
ANTHROPIC_BASE_URL = "http://localhost:11434" # Ollama v0.14+ speaks Anthropic
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import os
|
|
22
|
+
|
|
23
|
+
from .base import ChatResult, Turn
|
|
24
|
+
|
|
25
|
+
DEFAULT_TIMEOUT_S = 1800.0
|
|
26
|
+
MAX_OUTPUT_CHARS = 60_000
|
|
27
|
+
PROMPT_PLACEHOLDER = "{prompt}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CliBackend:
|
|
31
|
+
name = "cli"
|
|
32
|
+
uses_workspace = True
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
command: list[str],
|
|
37
|
+
model: str = "cli-agent",
|
|
38
|
+
timeout: float = DEFAULT_TIMEOUT_S,
|
|
39
|
+
env: dict[str, str] | None = None,
|
|
40
|
+
base_url: str | None = None, # accepted for config symmetry; unused
|
|
41
|
+
api_key: str | None = None,
|
|
42
|
+
extra_body: dict | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
if not command:
|
|
45
|
+
raise ValueError("cli backend needs a command")
|
|
46
|
+
if not any(PROMPT_PLACEHOLDER in part for part in command):
|
|
47
|
+
raise ValueError(f"cli backend command must contain {PROMPT_PLACEHOLDER}")
|
|
48
|
+
self.command = list(command)
|
|
49
|
+
self.model = model
|
|
50
|
+
self.timeout = timeout
|
|
51
|
+
self.env = env or {}
|
|
52
|
+
# Set per task by the harness before each call; None runs in the process cwd.
|
|
53
|
+
self.workspace: str | None = None
|
|
54
|
+
|
|
55
|
+
async def close(self) -> None:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
async def chat(
|
|
59
|
+
self,
|
|
60
|
+
system: str,
|
|
61
|
+
turns: list[Turn],
|
|
62
|
+
tools: list[dict] | None = None,
|
|
63
|
+
max_tokens: int = 2048,
|
|
64
|
+
temperature: float = 0.7,
|
|
65
|
+
) -> ChatResult:
|
|
66
|
+
del tools, max_tokens, temperature # the CLI agent brings its own
|
|
67
|
+
prompt = _flatten(system, turns)
|
|
68
|
+
argv = [part.replace(PROMPT_PLACEHOLDER, prompt) for part in self.command]
|
|
69
|
+
|
|
70
|
+
process = await asyncio.create_subprocess_exec(
|
|
71
|
+
*argv,
|
|
72
|
+
stdout=asyncio.subprocess.PIPE,
|
|
73
|
+
stderr=asyncio.subprocess.PIPE,
|
|
74
|
+
cwd=self.workspace,
|
|
75
|
+
env={**os.environ, **self.env},
|
|
76
|
+
)
|
|
77
|
+
try:
|
|
78
|
+
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=self.timeout)
|
|
79
|
+
except TimeoutError:
|
|
80
|
+
process.kill()
|
|
81
|
+
await process.wait()
|
|
82
|
+
raise RuntimeError(f"cli agent timed out after {self.timeout:.0f}s (killed)") from None
|
|
83
|
+
|
|
84
|
+
out = stdout.decode(errors="replace").strip()
|
|
85
|
+
if process.returncode != 0:
|
|
86
|
+
err = stderr.decode(errors="replace").strip()
|
|
87
|
+
raise RuntimeError(f"cli agent exited {process.returncode}: {(err or out)[:800]}")
|
|
88
|
+
return ChatResult(text=out[-MAX_OUTPUT_CHARS:], stop_reason="cli_exit")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _flatten(system: str, turns: list[Turn]) -> str:
|
|
92
|
+
"""One prompt for a one-shot CLI run: instructions first, then the conversation."""
|
|
93
|
+
parts = [system.strip()] if system else []
|
|
94
|
+
for turn in turns:
|
|
95
|
+
content = (turn.content or "").strip()
|
|
96
|
+
if not content:
|
|
97
|
+
continue
|
|
98
|
+
parts.append(content if turn.role == "user" else f"(your earlier reply) {content}")
|
|
99
|
+
return "\n\n".join(parts)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Any OpenAI-compatible /chat/completions endpoint: vLLM, LM Studio, Ollama, Tahoma."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .base import ChatResult, ToolCall, Turn
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class OpenAICompatBackend:
|
|
14
|
+
name = "openai_compat"
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
base_url: str,
|
|
19
|
+
model: str,
|
|
20
|
+
api_key: str | None = None,
|
|
21
|
+
timeout: float = 600.0,
|
|
22
|
+
extra_body: dict | None = None,
|
|
23
|
+
extra_headers: dict | None = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
self.base_url = base_url.rstrip("/")
|
|
26
|
+
self.model = model
|
|
27
|
+
self.extra_body = extra_body or {}
|
|
28
|
+
headers = {"Content-Type": "application/json"}
|
|
29
|
+
if api_key:
|
|
30
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
31
|
+
headers.update(extra_headers or {}) # e.g. OpenRouter's HTTP-Referer / X-Title
|
|
32
|
+
self._http = httpx.AsyncClient(headers=headers, timeout=timeout)
|
|
33
|
+
|
|
34
|
+
async def close(self) -> None:
|
|
35
|
+
await self._http.aclose()
|
|
36
|
+
|
|
37
|
+
async def chat(
|
|
38
|
+
self,
|
|
39
|
+
system: str,
|
|
40
|
+
turns: list[Turn],
|
|
41
|
+
tools: list[dict] | None = None,
|
|
42
|
+
max_tokens: int = 2048,
|
|
43
|
+
temperature: float = 0.7,
|
|
44
|
+
) -> ChatResult:
|
|
45
|
+
messages: list[dict[str, Any]] = [{"role": "system", "content": system}]
|
|
46
|
+
for turn in turns:
|
|
47
|
+
if turn.role == "assistant" and turn.tool_calls:
|
|
48
|
+
messages.append(
|
|
49
|
+
{
|
|
50
|
+
"role": "assistant",
|
|
51
|
+
"content": turn.content or None,
|
|
52
|
+
"tool_calls": [
|
|
53
|
+
{
|
|
54
|
+
"id": call.id,
|
|
55
|
+
"type": "function",
|
|
56
|
+
"function": {
|
|
57
|
+
"name": call.name,
|
|
58
|
+
"arguments": json.dumps(call.arguments),
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
for call in turn.tool_calls
|
|
62
|
+
],
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
else:
|
|
66
|
+
messages.append({"role": turn.role, "content": turn.content})
|
|
67
|
+
for result in turn.tool_results:
|
|
68
|
+
messages.append(
|
|
69
|
+
{
|
|
70
|
+
"role": "tool",
|
|
71
|
+
"tool_call_id": result.call_id,
|
|
72
|
+
"content": result.content,
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
payload: dict[str, Any] = {
|
|
77
|
+
"model": self.model,
|
|
78
|
+
"messages": messages,
|
|
79
|
+
"max_tokens": max_tokens,
|
|
80
|
+
"temperature": temperature,
|
|
81
|
+
**self.extra_body,
|
|
82
|
+
}
|
|
83
|
+
if tools:
|
|
84
|
+
payload["tools"] = [
|
|
85
|
+
{
|
|
86
|
+
"type": "function",
|
|
87
|
+
"function": {
|
|
88
|
+
"name": tool["name"],
|
|
89
|
+
"description": tool.get("description", ""),
|
|
90
|
+
"parameters": tool.get("input_schema", {"type": "object"}),
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
for tool in tools
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
response = await self._http.post(f"{self.base_url}/chat/completions", json=payload)
|
|
97
|
+
response.raise_for_status()
|
|
98
|
+
data = response.json()
|
|
99
|
+
choice = data["choices"][0]
|
|
100
|
+
message = choice.get("message", {})
|
|
101
|
+
calls = []
|
|
102
|
+
for raw in message.get("tool_calls") or []:
|
|
103
|
+
fn = raw.get("function", {})
|
|
104
|
+
try:
|
|
105
|
+
args = json.loads(fn.get("arguments") or "{}")
|
|
106
|
+
except json.JSONDecodeError:
|
|
107
|
+
args = {"_raw": fn.get("arguments")}
|
|
108
|
+
calls.append(
|
|
109
|
+
ToolCall(id=raw.get("id", fn.get("name", "call")), name=fn["name"], arguments=args)
|
|
110
|
+
)
|
|
111
|
+
usage = data.get("usage") or {}
|
|
112
|
+
return ChatResult(
|
|
113
|
+
text=message.get("content") or "",
|
|
114
|
+
tool_calls=calls,
|
|
115
|
+
input_tokens=usage.get("prompt_tokens", 0),
|
|
116
|
+
output_tokens=usage.get("completion_tokens", 0),
|
|
117
|
+
stop_reason=choice.get("finish_reason"),
|
|
118
|
+
)
|
yeschef/agent/config.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Agent configuration: one TOML file per agent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import socket
|
|
7
|
+
import sys
|
|
8
|
+
import tomllib
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from ..models import ReplyWhen
|
|
13
|
+
from ..settings import env as _settings_env
|
|
14
|
+
from ..tools.executor import ToolsConfig
|
|
15
|
+
|
|
16
|
+
DEFAULT_SYSTEM_PROMPT = (
|
|
17
|
+
"You are {name}, a cook in the kitchen working on node {node}. You collaborate "
|
|
18
|
+
"with Claude Code and with other local agents through a shared hub. Be direct and "
|
|
19
|
+
"concrete. When you are given a task, do the work and report the result; when you are in "
|
|
20
|
+
"a conversation, reply with substance and stop when the goal is met."
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True)
|
|
25
|
+
class AgentConfig:
|
|
26
|
+
name: str
|
|
27
|
+
hub: str = "http://localhost:8787"
|
|
28
|
+
node: str = field(default_factory=socket.gethostname)
|
|
29
|
+
tags: list[str] = field(default_factory=list)
|
|
30
|
+
register_token: str | None = None
|
|
31
|
+
|
|
32
|
+
backend: dict = field(default_factory=dict)
|
|
33
|
+
system_prompt: str = DEFAULT_SYSTEM_PROMPT
|
|
34
|
+
reply_when: ReplyWhen = ReplyWhen.MENTIONED
|
|
35
|
+
max_tokens: int = 2048
|
|
36
|
+
temperature: float = 0.7
|
|
37
|
+
max_context_messages: int = 30
|
|
38
|
+
max_tool_iterations: int = 8
|
|
39
|
+
max_concurrent_tasks: int = 1
|
|
40
|
+
tools: ToolsConfig = field(default_factory=ToolsConfig)
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def load(cls, path: str | Path) -> AgentConfig:
|
|
44
|
+
raw = tomllib.loads(Path(path).expanduser().read_text())
|
|
45
|
+
return cls.from_dict(raw)
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def from_dict(cls, raw: dict) -> AgentConfig:
|
|
49
|
+
known = {
|
|
50
|
+
"name",
|
|
51
|
+
"hub",
|
|
52
|
+
"node",
|
|
53
|
+
"tags",
|
|
54
|
+
"register_token",
|
|
55
|
+
"backend",
|
|
56
|
+
"persona",
|
|
57
|
+
"runtime",
|
|
58
|
+
"tools",
|
|
59
|
+
}
|
|
60
|
+
for key in raw:
|
|
61
|
+
if key not in known:
|
|
62
|
+
# A misplaced key (register_token under [tools], a typo) otherwise
|
|
63
|
+
# fails much later with an error that never mentions it.
|
|
64
|
+
print(
|
|
65
|
+
f"warning: unknown config key {key!r} ignored "
|
|
66
|
+
f"(known: {', '.join(sorted(known))})",
|
|
67
|
+
file=sys.stderr,
|
|
68
|
+
)
|
|
69
|
+
persona = raw.get("persona") or {}
|
|
70
|
+
runtime = raw.get("runtime") or {}
|
|
71
|
+
backend = dict(raw.get("backend") or {})
|
|
72
|
+
if "api_key_env" in backend:
|
|
73
|
+
backend["api_key"] = os.environ.get(backend.pop("api_key_env"))
|
|
74
|
+
node = raw.get("node") or socket.gethostname()
|
|
75
|
+
return cls(
|
|
76
|
+
name=raw["name"],
|
|
77
|
+
hub=raw.get("hub", "http://localhost:8787"),
|
|
78
|
+
node=node,
|
|
79
|
+
tags=list(raw.get("tags") or []),
|
|
80
|
+
register_token=raw.get("register_token") or _settings_env("REGISTER_TOKEN"),
|
|
81
|
+
backend=backend,
|
|
82
|
+
system_prompt=persona.get("system_prompt") or DEFAULT_SYSTEM_PROMPT,
|
|
83
|
+
reply_when=ReplyWhen(persona.get("reply_when", ReplyWhen.MENTIONED)),
|
|
84
|
+
max_tokens=int(runtime.get("max_tokens", 2048)),
|
|
85
|
+
temperature=float(runtime.get("temperature", 0.7)),
|
|
86
|
+
max_context_messages=int(runtime.get("max_context_messages", 30)),
|
|
87
|
+
max_tool_iterations=int(runtime.get("max_tool_iterations", 8)),
|
|
88
|
+
max_concurrent_tasks=int(runtime.get("max_concurrent_tasks", 1)),
|
|
89
|
+
tools=ToolsConfig.from_dict(raw.get("tools")),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def rendered_system_prompt(self) -> str:
|
|
93
|
+
return self.system_prompt.format(name=self.name, node=self.node)
|
|
94
|
+
|
|
95
|
+
def backend_label(self) -> str:
|
|
96
|
+
"""Prefer the detected runtime name (ollama, vllm) — it is what a human means."""
|
|
97
|
+
kind = self.backend.get("runtime") or self.backend.get("type", "openai_compat")
|
|
98
|
+
return f"{kind}/{self.backend.get('model', 'unknown')}"
|
yeschef/agent/detect.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Find a local model server so `join` can configure itself.
|
|
2
|
+
|
|
3
|
+
Probes the endpoints local runtimes conventionally listen on and reports what is there,
|
|
4
|
+
so an operator can bring a worker online without hand-writing a backend config.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
PROBE_TIMEOUT_S = 1.5
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(slots=True)
|
|
17
|
+
class DetectedBackend:
|
|
18
|
+
runtime: str # "ollama" | "vllm" | "lmstudio" | ...
|
|
19
|
+
base_url: str # OpenAI-compatible base, e.g. http://localhost:11434/v1
|
|
20
|
+
models: list[str]
|
|
21
|
+
|
|
22
|
+
def pick_model(self, preferred: str | None) -> str | None:
|
|
23
|
+
if not self.models:
|
|
24
|
+
return preferred
|
|
25
|
+
if preferred:
|
|
26
|
+
exact = [m for m in self.models if m == preferred]
|
|
27
|
+
if exact:
|
|
28
|
+
return exact[0]
|
|
29
|
+
partial = [m for m in self.models if preferred in m]
|
|
30
|
+
if partial:
|
|
31
|
+
return partial[0]
|
|
32
|
+
return self.models[0]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# host is filled in per probe so a worker can point at a model on another box.
|
|
36
|
+
_PROBES = [
|
|
37
|
+
("ollama", 11434, "/api/tags"),
|
|
38
|
+
("vllm", 8000, "/v1/models"),
|
|
39
|
+
("lmstudio", 1234, "/v1/models"),
|
|
40
|
+
("openai_compat", 8080, "/v1/models"), # Tahoma's usual port
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _models_from(runtime: str, payload: dict) -> list[str]:
|
|
45
|
+
if runtime == "ollama":
|
|
46
|
+
return [m.get("name") or m.get("model") for m in payload.get("models", []) if m]
|
|
47
|
+
data = payload.get("data") or []
|
|
48
|
+
return [entry.get("id") for entry in data if entry.get("id")]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def probe(host: str = "localhost", timeout: float = PROBE_TIMEOUT_S) -> list[DetectedBackend]:
|
|
52
|
+
"""Return every model server found on `host`, in priority order."""
|
|
53
|
+
found: list[DetectedBackend] = []
|
|
54
|
+
with httpx.Client(timeout=timeout) as client:
|
|
55
|
+
for runtime, port, path in _PROBES:
|
|
56
|
+
url = f"http://{host}:{port}{path}"
|
|
57
|
+
try:
|
|
58
|
+
response = client.get(url)
|
|
59
|
+
response.raise_for_status()
|
|
60
|
+
payload = response.json()
|
|
61
|
+
except (httpx.HTTPError, ValueError):
|
|
62
|
+
continue
|
|
63
|
+
models = [m for m in _models_from(runtime, payload) if m]
|
|
64
|
+
# Ollama and everything else expose an OpenAI-compatible base at /v1.
|
|
65
|
+
base = f"http://{host}:{port}/v1"
|
|
66
|
+
found.append(DetectedBackend(runtime=runtime, base_url=base, models=models))
|
|
67
|
+
return found
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def autodetect(host: str = "localhost") -> DetectedBackend | None:
|
|
71
|
+
"""The single best local backend, or None if nothing is listening."""
|
|
72
|
+
candidates = probe(host)
|
|
73
|
+
return candidates[0] if candidates else None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def preflight(backend_config: dict) -> tuple[bool, str]:
|
|
77
|
+
"""Prove a backend config actually answers before a worker commits to it.
|
|
78
|
+
|
|
79
|
+
A wrong base_url or model otherwise registers happily and only surfaces when the
|
|
80
|
+
first task fails. Returns (ok, human message).
|
|
81
|
+
"""
|
|
82
|
+
from .backends import build_backend
|
|
83
|
+
from .backends.base import Turn
|
|
84
|
+
|
|
85
|
+
backend = build_backend(backend_config)
|
|
86
|
+
try:
|
|
87
|
+
result = await backend.chat(
|
|
88
|
+
"You are a health check.",
|
|
89
|
+
[Turn(role="user", content="Reply with the single word: ok")],
|
|
90
|
+
max_tokens=8,
|
|
91
|
+
temperature=0.0,
|
|
92
|
+
)
|
|
93
|
+
except Exception as exc: # noqa: BLE001 - the whole point is to report the failure
|
|
94
|
+
return False, f"{type(exc).__name__}: {exc}"
|
|
95
|
+
finally:
|
|
96
|
+
await backend.close()
|
|
97
|
+
text = (result.text or "").strip()
|
|
98
|
+
return True, text or "(empty reply, but the endpoint answered)"
|