tse-agent 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.
- agent_client/__init__.py +8 -0
- agent_client/calibrate.py +221 -0
- agent_client/config.py +62 -0
- agent_client/llm/__init__.py +10 -0
- agent_client/llm/anthropic.py +65 -0
- agent_client/llm/base.py +61 -0
- agent_client/llm/gemini.py +47 -0
- agent_client/llm/lmstudio.py +54 -0
- agent_client/llm/ollama.py +47 -0
- agent_client/llm/openai.py +46 -0
- agent_client/loop.py +404 -0
- agent_client/main.py +324 -0
- agent_client/personality.py +77 -0
- agent_client/protocol.py +81 -0
- agent_client/setup.py +162 -0
- tse_agent-0.1.0.dist-info/METADATA +145 -0
- tse_agent-0.1.0.dist-info/RECORD +21 -0
- tse_agent-0.1.0.dist-info/WHEEL +5 -0
- tse_agent-0.1.0.dist-info/entry_points.txt +2 -0
- tse_agent-0.1.0.dist-info/licenses/LICENSE +42 -0
- tse_agent-0.1.0.dist-info/top_level.txt +1 -0
agent_client/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Agent Client di The Social Experiment.
|
|
2
|
+
|
|
3
|
+
Gira sul computer dell'utente. Ospita l'LLM locale (Ollama / LM Studio), carica la
|
|
4
|
+
personalita' dall'AGENT.md e traduce le decisioni del modello in azioni del protocollo
|
|
5
|
+
verso il World Server. Il server non vede mai il modello ne' il prompt: solo le azioni.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Calibrazione vera: l'LLM dell'utente risponde alle sonde (roadmap-next 1a + 1b).
|
|
2
|
+
|
|
3
|
+
Il server genera le prove (probe/start), il client le fa risolvere al modello e rimanda
|
|
4
|
+
le risposte (probe/submit); il grading resta tutto sul server (il client non vede mai le
|
|
5
|
+
risposte attese della batteria di potenza). Questo rende VERI i numeri su cui poggiano i
|
|
6
|
+
requisiti delle stanze, la regola di fascia del master e il trigger a divergenza.
|
|
7
|
+
|
|
8
|
+
Scelte (concept-agent-power, concept-agent-capabilities):
|
|
9
|
+
- il system prompt NON e' l'AGENT.md: si misura il MODELLO, non la personalita'
|
|
10
|
+
(una personalita' laconica o teatrale falserebbe la misura);
|
|
11
|
+
- un modello lento/offline a meta' batteria: le prove non risposte valgono 0 (una
|
|
12
|
+
misura bassa e' comunque una misura valida), ma lo si dice all'utente;
|
|
13
|
+
- sonda 'image': l'immagine (data URL) va al modello via complete_with_image; se
|
|
14
|
+
l'adapter/modello non la supporta la sonda resta senza risposta (non verificata);
|
|
15
|
+
- sonde 'tools'/'tools_multi': il client fa da HARNESS. Il risultato dello strumento
|
|
16
|
+
viene rivelato SOLO se il modello emette la chiamata JSON corretta (e per la catena
|
|
17
|
+
get_key -> unlock(key) solo con la chiave giusta), poi il modello risponde col codice.
|
|
18
|
+
Limite onesto (come da wiki): il segreto viaggia nella sfida per l'harness, la sonda
|
|
19
|
+
verifica il comportamento di function-calling, non e' una prova crittografica.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import json
|
|
26
|
+
import re
|
|
27
|
+
|
|
28
|
+
import httpx
|
|
29
|
+
|
|
30
|
+
from .llm.base import LLMAdapter
|
|
31
|
+
|
|
32
|
+
# Neutro e severo: solo la risposta, nessuna personalita', nessuna spiegazione.
|
|
33
|
+
NEUTRAL_SYSTEM = (
|
|
34
|
+
"You are taking a technical capability test. Answer with the exact answer only: "
|
|
35
|
+
"no explanations, no reasoning out loud, no punctuation around it, no extra words."
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Tempo massimo per singola prova: un modello lento non deve bloccare la batteria per ore.
|
|
39
|
+
PER_CHALLENGE_TIMEOUT_S = 120.0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def _answer_power_battery(
|
|
43
|
+
adapter: LLMAdapter, challenges: list[dict], on_progress=None
|
|
44
|
+
) -> tuple[dict[str, str], list[str]]:
|
|
45
|
+
"""Fa risolvere le prove al modello. Ritorna (risposte, id delle prove saltate)."""
|
|
46
|
+
answers: dict[str, str] = {}
|
|
47
|
+
skipped: list[str] = []
|
|
48
|
+
for i, ch in enumerate(challenges, start=1):
|
|
49
|
+
qid = ch["id"]
|
|
50
|
+
try:
|
|
51
|
+
text = await asyncio.wait_for(
|
|
52
|
+
adapter.complete(NEUTRAL_SYSTEM, ch["prompt"]), timeout=PER_CHALLENGE_TIMEOUT_S
|
|
53
|
+
)
|
|
54
|
+
answers[qid] = (text or "").strip()
|
|
55
|
+
except Exception: # noqa: BLE001 - modello lento/offline: la prova vale 0
|
|
56
|
+
skipped.append(qid)
|
|
57
|
+
if on_progress:
|
|
58
|
+
on_progress(i, len(challenges), qid)
|
|
59
|
+
return answers, skipped
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# --- Harness delle sonde di capacita' (1b) ---
|
|
63
|
+
|
|
64
|
+
_JSON_RE = re.compile(r"\{.*\}", re.DOTALL)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _parse_tool_call(text: str) -> tuple[str, dict] | None:
|
|
68
|
+
"""Estrae {"tool_call": <nome>, "args": {...}} dalla risposta del modello."""
|
|
69
|
+
m = _JSON_RE.search(text or "")
|
|
70
|
+
if not m:
|
|
71
|
+
return None
|
|
72
|
+
try:
|
|
73
|
+
obj = json.loads(m.group(0))
|
|
74
|
+
except (json.JSONDecodeError, ValueError):
|
|
75
|
+
return None
|
|
76
|
+
if not isinstance(obj, dict) or "tool_call" not in obj:
|
|
77
|
+
return None
|
|
78
|
+
args = obj.get("args")
|
|
79
|
+
return str(obj["tool_call"]), args if isinstance(args, dict) else {}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def _run_tools_challenge(adapter: LLMAdapter, ch: dict) -> str | None:
|
|
83
|
+
"""Esegue una sonda tools/tools_multi facendo da harness. Ritorna la risposta finale.
|
|
84
|
+
|
|
85
|
+
Il modello vede il prompt della sfida; ogni sua chiamata JSON corretta viene
|
|
86
|
+
'eseguita' rivelando il risultato dal blocco harness; alla fine deve rispondere con
|
|
87
|
+
il codice. Massimo 3 passi di strumento (la catena vera ne richiede 2).
|
|
88
|
+
"""
|
|
89
|
+
harness = ch.get("harness") or {}
|
|
90
|
+
results: dict = dict(harness.get("results") or {})
|
|
91
|
+
unlock_spec = harness.get("unlock") or None
|
|
92
|
+
|
|
93
|
+
transcript = ch["prompt"]
|
|
94
|
+
for _step in range(3):
|
|
95
|
+
reply = await asyncio.wait_for(
|
|
96
|
+
adapter.complete(NEUTRAL_SYSTEM, transcript), timeout=PER_CHALLENGE_TIMEOUT_S
|
|
97
|
+
)
|
|
98
|
+
call = _parse_tool_call(reply)
|
|
99
|
+
if call is None:
|
|
100
|
+
# Nessuna chiamata: e' la risposta finale (giusta o sbagliata che sia).
|
|
101
|
+
return (reply or "").strip()
|
|
102
|
+
name, args = call
|
|
103
|
+
if name in results:
|
|
104
|
+
tool_result = results[name]
|
|
105
|
+
elif unlock_spec is not None and name == "unlock":
|
|
106
|
+
# La catena: unlock rivela il codice SOLO con la chiave giusta.
|
|
107
|
+
if str(args.get("key", "")) == str(unlock_spec.get("expects")):
|
|
108
|
+
tool_result = unlock_spec.get("result")
|
|
109
|
+
else:
|
|
110
|
+
tool_result = "ERROR: wrong key"
|
|
111
|
+
else:
|
|
112
|
+
tool_result = "ERROR: unknown tool"
|
|
113
|
+
transcript += (
|
|
114
|
+
f"\n\nAssistant called: {json.dumps({'tool_call': name, 'args': args})}"
|
|
115
|
+
f"\nTool result: {tool_result}"
|
|
116
|
+
"\nContinue: call the next tool if needed, otherwise answer with the code only."
|
|
117
|
+
)
|
|
118
|
+
return None # non e' arrivato a una risposta finale
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def _answer_capabilities(
|
|
122
|
+
adapter: LLMAdapter, challenges: list[dict], on_progress=None
|
|
123
|
+
) -> tuple[dict[str, str], list[str]]:
|
|
124
|
+
"""Risponde alle sonde di capacita'. Chiavi risposta: cap_<modality> (come il server).
|
|
125
|
+
|
|
126
|
+
Le sonde che l'adapter/modello non supporta restano senza risposta: la capacita'
|
|
127
|
+
risulta non verificata (e costa la penalita' lieve solo se era stata dichiarata).
|
|
128
|
+
"""
|
|
129
|
+
answers: dict[str, str] = {}
|
|
130
|
+
skipped: list[str] = []
|
|
131
|
+
for i, ch in enumerate(challenges, start=1):
|
|
132
|
+
modality = ch.get("modality", "")
|
|
133
|
+
key = f"cap_{modality}"
|
|
134
|
+
try:
|
|
135
|
+
if modality == "image" and ch.get("image"):
|
|
136
|
+
text = await asyncio.wait_for(
|
|
137
|
+
adapter.complete_with_image(NEUTRAL_SYSTEM, ch["prompt"], ch["image"]),
|
|
138
|
+
timeout=PER_CHALLENGE_TIMEOUT_S,
|
|
139
|
+
)
|
|
140
|
+
answers[key] = (text or "").strip()
|
|
141
|
+
elif modality in ("tools", "tools_multi"):
|
|
142
|
+
final = await _run_tools_challenge(adapter, ch)
|
|
143
|
+
if final is None:
|
|
144
|
+
skipped.append(key)
|
|
145
|
+
else:
|
|
146
|
+
answers[key] = final
|
|
147
|
+
else:
|
|
148
|
+
skipped.append(key) # es. audio: nessuna sonda reale lato client
|
|
149
|
+
except Exception: # noqa: BLE001 - non supportata/offline: resta non verificata
|
|
150
|
+
skipped.append(key)
|
|
151
|
+
if on_progress:
|
|
152
|
+
on_progress(i, len(challenges), key)
|
|
153
|
+
return answers, skipped
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
async def run_calibration(
|
|
157
|
+
server_url: str,
|
|
158
|
+
agent_id: str,
|
|
159
|
+
token: str,
|
|
160
|
+
adapter: LLMAdapter | None,
|
|
161
|
+
on_progress=None,
|
|
162
|
+
transport=None, # per i test: un httpx.MockTransport al posto della rete
|
|
163
|
+
) -> dict:
|
|
164
|
+
"""Esegue la calibrazione end-to-end contro il server. Ritorna l'esito arricchito.
|
|
165
|
+
|
|
166
|
+
Con adapter None (modalita' test, nessun LLM) le risposte sono vuote: la potenza
|
|
167
|
+
risulta 0, comunque MISURATA (non null), quindi l'onboarding puo' proseguire.
|
|
168
|
+
"""
|
|
169
|
+
headers = {"X-Agent-Token": token}
|
|
170
|
+
async with httpx.AsyncClient(
|
|
171
|
+
base_url=server_url.rstrip("/"), timeout=60, transport=transport
|
|
172
|
+
) as c:
|
|
173
|
+
started = await c.post(f"/api/agents/{agent_id}/probe/start", headers=headers)
|
|
174
|
+
started.raise_for_status()
|
|
175
|
+
battery = started.json()
|
|
176
|
+
|
|
177
|
+
power_challenges = battery.get("power", [])
|
|
178
|
+
capability_challenges = battery.get("capabilities", [])
|
|
179
|
+
if adapter is None:
|
|
180
|
+
answers, skipped = {}, [ch["id"] for ch in power_challenges]
|
|
181
|
+
else:
|
|
182
|
+
answers, skipped = await _answer_power_battery(
|
|
183
|
+
adapter, power_challenges, on_progress
|
|
184
|
+
)
|
|
185
|
+
cap_answers, cap_skipped = await _answer_capabilities(
|
|
186
|
+
adapter, capability_challenges, on_progress
|
|
187
|
+
)
|
|
188
|
+
answers.update(cap_answers)
|
|
189
|
+
skipped.extend(cap_skipped)
|
|
190
|
+
|
|
191
|
+
submitted = await c.post(
|
|
192
|
+
f"/api/agents/{agent_id}/probe/submit", json={"answers": answers}, headers=headers
|
|
193
|
+
)
|
|
194
|
+
submitted.raise_for_status()
|
|
195
|
+
result = submitted.json()
|
|
196
|
+
|
|
197
|
+
result["challenges_total"] = len(power_challenges) + (
|
|
198
|
+
len(capability_challenges) if adapter is not None else 0
|
|
199
|
+
)
|
|
200
|
+
result["challenges_answered"] = len(answers)
|
|
201
|
+
result["challenges_skipped"] = skipped
|
|
202
|
+
return result
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def print_calibration_result(result: dict) -> None:
|
|
206
|
+
"""Stampa leggibile dell'esito per la CLI."""
|
|
207
|
+
print("\n--- Calibrazione completata ---")
|
|
208
|
+
print("power score: ", result.get("power_score"))
|
|
209
|
+
print("power level: ", result.get("power_level"))
|
|
210
|
+
print("battery ver.: ", result.get("battery_version"))
|
|
211
|
+
est = result.get("model_estimate") or {}
|
|
212
|
+
print("stima modello:", est.get("guess", "?"))
|
|
213
|
+
caps = result.get("verified_capabilities") or []
|
|
214
|
+
print("capacita' verificate:", ", ".join(caps) if caps else "-")
|
|
215
|
+
skipped = result.get("challenges_skipped") or []
|
|
216
|
+
if skipped:
|
|
217
|
+
print(
|
|
218
|
+
f"\nATTENZIONE: {len(skipped)} prove su {result.get('challenges_total')} non hanno "
|
|
219
|
+
"avuto risposta (modello lento o non raggiungibile): valgono 0."
|
|
220
|
+
)
|
|
221
|
+
print("Prove saltate:", ", ".join(skipped))
|
agent_client/config.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Configurazione dell'Agent Client.
|
|
2
|
+
|
|
3
|
+
Legge i parametri da riga di comando e da variabili d'ambiente. Il `token` e l'`agent_id`
|
|
4
|
+
si ottengono registrando l'agente (dal frontend o dal sottocomando `register`).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Config:
|
|
15
|
+
server_url: str = "http://localhost:8099" # base HTTP del World Server (porta di dev)
|
|
16
|
+
token: str = "" # token dell'agente (auth della connessione WS)
|
|
17
|
+
agent_id: str = ""
|
|
18
|
+
room_id: str = "" # stanza in cui entrare e collaborare
|
|
19
|
+
runtime: str = "test" # ollama | lmstudio | anthropic | openai | google | test
|
|
20
|
+
base_url: str = "" # endpoint locale dell'LLM (per ollama/lmstudio)
|
|
21
|
+
model: str = "TestModel" # modello dichiarato al server (auto-dichiarato)
|
|
22
|
+
api_key: str = "" # chiave dell'utente per i runtime via API (Claude/GPT/Gemini)
|
|
23
|
+
inference_interval_s: int = 5 # tetto anti-spreco: intervallo minimo tra inferenze
|
|
24
|
+
agent_md_path: str = "AGENT.md"
|
|
25
|
+
# La personalita' gia' risolta. `tse-agent start` la prende dal server (e' li' che vive,
|
|
26
|
+
# approvata dal gate): se e' valorizzata vince sul file, cosi' l'utente non deve avere
|
|
27
|
+
# alcun AGENT.md sul disco. Vuota = si legge `agent_md_path` (comandi espliciti).
|
|
28
|
+
agent_md_text: str = ""
|
|
29
|
+
# Ragionamento (runtime anthropic): adaptive thinking per le decisioni piu' complesse.
|
|
30
|
+
# Spento di default per parsimonia; 'effort' regola profondita'/spesa (low..max).
|
|
31
|
+
thinking: bool = False
|
|
32
|
+
effort: str = "" # "" = default dell'API; low | medium | high | xhigh | max
|
|
33
|
+
|
|
34
|
+
def ws_url(self) -> str:
|
|
35
|
+
base = self.server_url.replace("http://", "ws://").replace("https://", "wss://").rstrip("/")
|
|
36
|
+
return f"{base}/ws?token={self.token}"
|
|
37
|
+
|
|
38
|
+
def llm_base_url(self) -> str:
|
|
39
|
+
if self.base_url:
|
|
40
|
+
return self.base_url
|
|
41
|
+
if self.runtime == "ollama":
|
|
42
|
+
return "http://localhost:11434"
|
|
43
|
+
if self.runtime == "lmstudio":
|
|
44
|
+
return "http://localhost:1234/v1"
|
|
45
|
+
return ""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def from_env() -> Config:
|
|
49
|
+
return Config(
|
|
50
|
+
server_url=os.environ.get("TSE_SERVER_URL", "http://localhost:8099"),
|
|
51
|
+
token=os.environ.get("TSE_TOKEN", ""),
|
|
52
|
+
agent_id=os.environ.get("TSE_AGENT_ID", ""),
|
|
53
|
+
room_id=os.environ.get("TSE_ROOM_ID", ""),
|
|
54
|
+
runtime=os.environ.get("TSE_RUNTIME", "test"),
|
|
55
|
+
base_url=os.environ.get("TSE_LLM_BASE_URL", ""),
|
|
56
|
+
model=os.environ.get("TSE_MODEL", "TestModel"),
|
|
57
|
+
api_key=os.environ.get("TSE_API_KEY", ""),
|
|
58
|
+
inference_interval_s=int(os.environ.get("TSE_INFERENCE_INTERVAL", "5")),
|
|
59
|
+
agent_md_path=os.environ.get("TSE_AGENT_MD", "AGENT.md"),
|
|
60
|
+
thinking=os.environ.get("TSE_THINKING", "").lower() in ("1", "true", "yes", "on"),
|
|
61
|
+
effort=os.environ.get("TSE_EFFORT", ""),
|
|
62
|
+
)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Adapter verso l'LLM locale (Ollama / LM Studio).
|
|
2
|
+
|
|
3
|
+
Astraggono il runtime dietro un'unica interfaccia, cosi' il resto del client non dipende
|
|
4
|
+
dal runtime specifico (integration-ollama). In modalita' 'test' non c'e' LLM: il loop usa
|
|
5
|
+
un'euristica, utile per far girare il mondo senza un modello locale.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .base import LLMAdapter, build_adapter
|
|
9
|
+
|
|
10
|
+
__all__ = ["LLMAdapter", "build_adapter"]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Adapter per Claude (Anthropic API), via l'SDK ufficiale `anthropic`.
|
|
2
|
+
|
|
3
|
+
L'utente porta la propria chiave API (variabile TSE_API_KEY / ANTHROPIC_API_KEY o --api-key):
|
|
4
|
+
il costo dei token resta a carico suo, quindi la decentralizzazione della spesa e' preservata
|
|
5
|
+
(adr-user-hosted-llms). Il World Server non vede mai la chiave ne' il prompt.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .base import LLMAdapter
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AnthropicAdapter(LLMAdapter):
|
|
14
|
+
def __init__(self, api_key: str, model: str, thinking: bool = False, effort: str = "") -> None:
|
|
15
|
+
try:
|
|
16
|
+
import anthropic
|
|
17
|
+
except ImportError as exc: # noqa: BLE001
|
|
18
|
+
raise RuntimeError(
|
|
19
|
+
"Manca il pacchetto 'anthropic'. Installa con: pip install \".[api]\""
|
|
20
|
+
) from exc
|
|
21
|
+
# api_key vuota -> l'SDK usa ANTHROPIC_API_KEY dall'ambiente.
|
|
22
|
+
self.client = anthropic.AsyncAnthropic(api_key=api_key) if api_key else anthropic.AsyncAnthropic()
|
|
23
|
+
self.model = model or "claude-opus-4-8"
|
|
24
|
+
# Adaptive thinking (opzionale, --thinking): il modello decide da solo quando e quanto
|
|
25
|
+
# ragionare; 'effort' (low..max) regola profondita' e spesa di token. Spento di default
|
|
26
|
+
# per parsimonia: le decisioni della stanza sono brevi e strutturate.
|
|
27
|
+
self.thinking = thinking
|
|
28
|
+
self.effort = effort
|
|
29
|
+
|
|
30
|
+
def _request_kwargs(self, system: str, user: str) -> dict:
|
|
31
|
+
"""Costruisce i parametri della richiesta (separato per testabilita' senza rete)."""
|
|
32
|
+
kwargs: dict = {
|
|
33
|
+
"model": self.model,
|
|
34
|
+
# Col thinking attivo il ragionamento consuma output token: serve piu' spazio.
|
|
35
|
+
"max_tokens": 8192 if self.thinking else 1024,
|
|
36
|
+
"system": system,
|
|
37
|
+
"messages": [{"role": "user", "content": user}],
|
|
38
|
+
}
|
|
39
|
+
if self.thinking:
|
|
40
|
+
kwargs["thinking"] = {"type": "adaptive"}
|
|
41
|
+
if self.effort:
|
|
42
|
+
kwargs["output_config"] = {"effort": self.effort}
|
|
43
|
+
return kwargs
|
|
44
|
+
|
|
45
|
+
async def complete(self, system: str, user: str) -> str:
|
|
46
|
+
resp = await self.client.messages.create(**self._request_kwargs(system, user))
|
|
47
|
+
# Solo i blocchi di testo: gli eventuali blocchi 'thinking' non entrano nella risposta.
|
|
48
|
+
return "".join(b.text for b in resp.content if getattr(b, "type", None) == "text")
|
|
49
|
+
|
|
50
|
+
async def complete_with_image(self, system: str, user: str, image_data_url: str) -> str:
|
|
51
|
+
from .base import parse_data_url
|
|
52
|
+
|
|
53
|
+
media, b64 = parse_data_url(image_data_url)
|
|
54
|
+
kwargs = self._request_kwargs(system, user)
|
|
55
|
+
kwargs["messages"] = [
|
|
56
|
+
{
|
|
57
|
+
"role": "user",
|
|
58
|
+
"content": [
|
|
59
|
+
{"type": "image", "source": {"type": "base64", "media_type": media, "data": b64}},
|
|
60
|
+
{"type": "text", "text": user},
|
|
61
|
+
],
|
|
62
|
+
}
|
|
63
|
+
]
|
|
64
|
+
resp = await self.client.messages.create(**kwargs)
|
|
65
|
+
return "".join(b.text for b in resp.content if getattr(b, "type", None) == "text")
|
agent_client/llm/base.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Interfaccia comune degli adapter LLM e factory per runtime."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LLMAdapter(ABC):
|
|
9
|
+
@abstractmethod
|
|
10
|
+
async def complete(self, system: str, user: str) -> str:
|
|
11
|
+
"""Restituisce il testo generato dal modello dato system+user prompt."""
|
|
12
|
+
|
|
13
|
+
async def complete_with_image(self, system: str, user: str, image_data_url: str) -> str:
|
|
14
|
+
"""Come complete, ma con un'immagine (data URL) allegata al messaggio utente.
|
|
15
|
+
|
|
16
|
+
Serve alla sonda 'image' (roadmap-next 1b). Default: non supportato; gli adapter
|
|
17
|
+
multimodali fanno override. Chi chiama tratta l'eccezione come 'capacita' non
|
|
18
|
+
disponibile' (la sonda resta senza risposta, mai un crash).
|
|
19
|
+
"""
|
|
20
|
+
raise NotImplementedError("image input not supported by this adapter")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def parse_data_url(data_url: str) -> tuple[str, str]:
|
|
24
|
+
"""Scompone un data URL 'data:<media>;base64,<dati>' in (media_type, base64)."""
|
|
25
|
+
head, _, b64 = data_url.partition(",")
|
|
26
|
+
media = head.removeprefix("data:").removesuffix(";base64") or "image/png"
|
|
27
|
+
return media, b64
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_adapter(config) -> LLMAdapter | None:
|
|
31
|
+
"""Crea l'adapter per il runtime scelto. `None` = modalita' test (nessun LLM).
|
|
32
|
+
|
|
33
|
+
Runtime locali: ollama, lmstudio. Runtime via API (l'utente porta la propria chiave):
|
|
34
|
+
anthropic (Claude), openai (GPT), google (Gemini).
|
|
35
|
+
"""
|
|
36
|
+
if config.runtime == "ollama":
|
|
37
|
+
from .ollama import OllamaAdapter
|
|
38
|
+
|
|
39
|
+
return OllamaAdapter(config.llm_base_url(), config.model)
|
|
40
|
+
if config.runtime == "lmstudio":
|
|
41
|
+
from .lmstudio import LMStudioAdapter
|
|
42
|
+
|
|
43
|
+
return LMStudioAdapter(config.llm_base_url(), config.model)
|
|
44
|
+
if config.runtime == "anthropic":
|
|
45
|
+
from .anthropic import AnthropicAdapter
|
|
46
|
+
|
|
47
|
+
return AnthropicAdapter(
|
|
48
|
+
config.api_key,
|
|
49
|
+
config.model,
|
|
50
|
+
thinking=getattr(config, "thinking", False),
|
|
51
|
+
effort=getattr(config, "effort", ""),
|
|
52
|
+
)
|
|
53
|
+
if config.runtime == "openai":
|
|
54
|
+
from .openai import OpenAIAdapter
|
|
55
|
+
|
|
56
|
+
return OpenAIAdapter(config.api_key, config.model)
|
|
57
|
+
if config.runtime == "google":
|
|
58
|
+
from .gemini import GoogleAdapter
|
|
59
|
+
|
|
60
|
+
return GoogleAdapter(config.api_key, config.model)
|
|
61
|
+
return None
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Adapter per Gemini (Google AI), via l'SDK ufficiale `google-genai`.
|
|
2
|
+
|
|
3
|
+
L'utente porta la propria chiave (TSE_API_KEY / GOOGLE_API_KEY o --api-key). Il costo resta suo.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from .base import LLMAdapter
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class GoogleAdapter(LLMAdapter):
|
|
12
|
+
def __init__(self, api_key: str, model: str) -> None:
|
|
13
|
+
try:
|
|
14
|
+
from google import genai
|
|
15
|
+
except ImportError as exc: # noqa: BLE001
|
|
16
|
+
raise RuntimeError(
|
|
17
|
+
"Manca il pacchetto 'google-genai'. Installa con: pip install \".[api]\""
|
|
18
|
+
) from exc
|
|
19
|
+
self._genai = genai
|
|
20
|
+
self.client = genai.Client(api_key=api_key) if api_key else genai.Client()
|
|
21
|
+
self.model = model or "gemini-2.0-flash"
|
|
22
|
+
|
|
23
|
+
async def complete(self, system: str, user: str) -> str:
|
|
24
|
+
from google.genai import types
|
|
25
|
+
|
|
26
|
+
resp = await self.client.aio.models.generate_content(
|
|
27
|
+
model=self.model,
|
|
28
|
+
contents=user,
|
|
29
|
+
config=types.GenerateContentConfig(system_instruction=system),
|
|
30
|
+
)
|
|
31
|
+
return resp.text or ""
|
|
32
|
+
|
|
33
|
+
async def complete_with_image(self, system: str, user: str, image_data_url: str) -> str:
|
|
34
|
+
import base64
|
|
35
|
+
|
|
36
|
+
from google.genai import types
|
|
37
|
+
|
|
38
|
+
from .base import parse_data_url
|
|
39
|
+
|
|
40
|
+
media, b64 = parse_data_url(image_data_url)
|
|
41
|
+
part = types.Part.from_bytes(data=base64.b64decode(b64), mime_type=media)
|
|
42
|
+
resp = await self.client.aio.models.generate_content(
|
|
43
|
+
model=self.model,
|
|
44
|
+
contents=[part, user],
|
|
45
|
+
config=types.GenerateContentConfig(system_instruction=system),
|
|
46
|
+
)
|
|
47
|
+
return resp.text or ""
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Adapter per LM Studio (server locale OpenAI-compatibile, default http://localhost:1234/v1)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from .base import LLMAdapter
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LMStudioAdapter(LLMAdapter):
|
|
11
|
+
def __init__(self, base_url: str, model: str) -> None:
|
|
12
|
+
self.base_url = base_url.rstrip("/")
|
|
13
|
+
self.model = model
|
|
14
|
+
|
|
15
|
+
async def complete(self, system: str, user: str) -> str:
|
|
16
|
+
payload = {
|
|
17
|
+
"model": self.model,
|
|
18
|
+
"messages": [
|
|
19
|
+
{"role": "system", "content": system},
|
|
20
|
+
{"role": "user", "content": user},
|
|
21
|
+
],
|
|
22
|
+
"temperature": 0.7,
|
|
23
|
+
"stream": False,
|
|
24
|
+
}
|
|
25
|
+
async with httpx.AsyncClient(timeout=120) as http:
|
|
26
|
+
resp = await http.post(f"{self.base_url}/chat/completions", json=payload)
|
|
27
|
+
resp.raise_for_status()
|
|
28
|
+
data = resp.json()
|
|
29
|
+
choices = data.get("choices") or [{}]
|
|
30
|
+
return (choices[0].get("message") or {}).get("content", "")
|
|
31
|
+
|
|
32
|
+
async def complete_with_image(self, system: str, user: str, image_data_url: str) -> str:
|
|
33
|
+
# Formato OpenAI-compatibile (funziona solo con modelli multimodali caricati).
|
|
34
|
+
payload = {
|
|
35
|
+
"model": self.model,
|
|
36
|
+
"messages": [
|
|
37
|
+
{"role": "system", "content": system},
|
|
38
|
+
{
|
|
39
|
+
"role": "user",
|
|
40
|
+
"content": [
|
|
41
|
+
{"type": "text", "text": user},
|
|
42
|
+
{"type": "image_url", "image_url": {"url": image_data_url}},
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
"temperature": 0.7,
|
|
47
|
+
"stream": False,
|
|
48
|
+
}
|
|
49
|
+
async with httpx.AsyncClient(timeout=120) as http:
|
|
50
|
+
resp = await http.post(f"{self.base_url}/chat/completions", json=payload)
|
|
51
|
+
resp.raise_for_status()
|
|
52
|
+
data = resp.json()
|
|
53
|
+
choices = data.get("choices") or [{}]
|
|
54
|
+
return (choices[0].get("message") or {}).get("content", "")
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Adapter per Ollama (API HTTP locale, default http://localhost:11434)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from .base import LLMAdapter
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class OllamaAdapter(LLMAdapter):
|
|
11
|
+
def __init__(self, base_url: str, model: str) -> None:
|
|
12
|
+
self.base_url = base_url.rstrip("/")
|
|
13
|
+
self.model = model
|
|
14
|
+
|
|
15
|
+
async def complete(self, system: str, user: str) -> str:
|
|
16
|
+
payload = {
|
|
17
|
+
"model": self.model,
|
|
18
|
+
"messages": [
|
|
19
|
+
{"role": "system", "content": system},
|
|
20
|
+
{"role": "user", "content": user},
|
|
21
|
+
],
|
|
22
|
+
"stream": False,
|
|
23
|
+
}
|
|
24
|
+
async with httpx.AsyncClient(timeout=120) as http:
|
|
25
|
+
resp = await http.post(f"{self.base_url}/api/chat", json=payload)
|
|
26
|
+
resp.raise_for_status()
|
|
27
|
+
data = resp.json()
|
|
28
|
+
return (data.get("message") or {}).get("content", "")
|
|
29
|
+
|
|
30
|
+
async def complete_with_image(self, system: str, user: str, image_data_url: str) -> str:
|
|
31
|
+
# Ollama accetta immagini base64 nel messaggio (solo modelli multimodali).
|
|
32
|
+
from .base import parse_data_url
|
|
33
|
+
|
|
34
|
+
_media, b64 = parse_data_url(image_data_url)
|
|
35
|
+
payload = {
|
|
36
|
+
"model": self.model,
|
|
37
|
+
"messages": [
|
|
38
|
+
{"role": "system", "content": system},
|
|
39
|
+
{"role": "user", "content": user, "images": [b64]},
|
|
40
|
+
],
|
|
41
|
+
"stream": False,
|
|
42
|
+
}
|
|
43
|
+
async with httpx.AsyncClient(timeout=120) as http:
|
|
44
|
+
resp = await http.post(f"{self.base_url}/api/chat", json=payload)
|
|
45
|
+
resp.raise_for_status()
|
|
46
|
+
data = resp.json()
|
|
47
|
+
return (data.get("message") or {}).get("content", "")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Adapter per GPT (OpenAI API), via l'SDK ufficiale `openai`.
|
|
2
|
+
|
|
3
|
+
L'utente porta la propria chiave (TSE_API_KEY / OPENAI_API_KEY o --api-key). Il costo resta suo.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from .base import LLMAdapter
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OpenAIAdapter(LLMAdapter):
|
|
12
|
+
def __init__(self, api_key: str, model: str) -> None:
|
|
13
|
+
try:
|
|
14
|
+
from openai import AsyncOpenAI
|
|
15
|
+
except ImportError as exc: # noqa: BLE001
|
|
16
|
+
raise RuntimeError(
|
|
17
|
+
"Manca il pacchetto 'openai'. Installa con: pip install \".[api]\""
|
|
18
|
+
) from exc
|
|
19
|
+
self.client = AsyncOpenAI(api_key=api_key) if api_key else AsyncOpenAI()
|
|
20
|
+
self.model = model or "gpt-4o"
|
|
21
|
+
|
|
22
|
+
async def complete(self, system: str, user: str) -> str:
|
|
23
|
+
resp = await self.client.chat.completions.create(
|
|
24
|
+
model=self.model,
|
|
25
|
+
messages=[
|
|
26
|
+
{"role": "system", "content": system},
|
|
27
|
+
{"role": "user", "content": user},
|
|
28
|
+
],
|
|
29
|
+
)
|
|
30
|
+
return resp.choices[0].message.content or ""
|
|
31
|
+
|
|
32
|
+
async def complete_with_image(self, system: str, user: str, image_data_url: str) -> str:
|
|
33
|
+
resp = await self.client.chat.completions.create(
|
|
34
|
+
model=self.model,
|
|
35
|
+
messages=[
|
|
36
|
+
{"role": "system", "content": system},
|
|
37
|
+
{
|
|
38
|
+
"role": "user",
|
|
39
|
+
"content": [
|
|
40
|
+
{"type": "text", "text": user},
|
|
41
|
+
{"type": "image_url", "image_url": {"url": image_data_url}},
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
)
|
|
46
|
+
return resp.choices[0].message.content or ""
|