llamagent 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.
llamagent/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """llamagent : un agent de code minimal autour d'Ollama."""
llamagent/agent.py ADDED
@@ -0,0 +1,124 @@
1
+ """La boucle agentique : le cœur de llamagent.
2
+
3
+ tant que le modèle demande des outils :
4
+ exécuter chaque outil demandé
5
+ ajouter le résultat à la conversation
6
+ rappeler le modèle
7
+ sinon : renvoyer sa réponse finale
8
+
9
+ La boucle ne connaît aucun outil en particulier : elle passe par le registre
10
+ de tools.py. Elle ne parle pas non plus directement à Ollama : on lui donne
11
+ une fonction `chat`, ce qui permet de la tester avec un faux modèle.
12
+ """
13
+
14
+ import json
15
+ import os
16
+ import platform
17
+ from pathlib import Path
18
+ from typing import Callable
19
+
20
+ from llamagent import ui
21
+ from llamagent.config import MAX_OUTPUT_CHARS, MAX_TOURS
22
+ from llamagent.guards import is_allowed_command, truncate
23
+ from llamagent.tools import TOOLS, ToolContext, execute_tool, tool_schemas
24
+
25
+ # Une fonction qui reçoit (messages, schémas des outils) et renvoie le message
26
+ # de l'assistant. En vrai : llm.chat. Dans les tests : un faux modèle.
27
+ ChatFunction = Callable[[list[dict], list[dict]], dict]
28
+
29
+
30
+ # ─────────────────────────── Prompt système ───────────────────────────
31
+
32
+
33
+ def build_system_prompt(template: str, workdir: Path, skills: dict[str, str], project_instructions: str = "") -> str:
34
+ """Remplit le modèle de prompt (prompts/AGENT.md) avec l'environnement réel.
35
+
36
+ Si le projet a un fichier AGENTS.md (étape 9), son contenu est ajouté à la fin.
37
+ """
38
+ shell = "cmd.exe" if os.name == "nt" else "/bin/sh"
39
+ skills_text = "\n".join(f"- {name} : {description}" for name, description in skills.items())
40
+ # str.replace plutôt que str.format : le Markdown peut contenir des { }.
41
+ prompt = (
42
+ template.replace("{os}", platform.system())
43
+ .replace("{shell}", shell)
44
+ .replace("{workdir}", str(workdir))
45
+ .replace("{skills}", skills_text or "(aucune)")
46
+ )
47
+ if project_instructions:
48
+ prompt += f"\n\n## Instructions du projet (AGENTS.md)\n{project_instructions}"
49
+ return prompt
50
+
51
+
52
+ # ─────────────────────────── La boucle ───────────────────────────
53
+
54
+
55
+ def describe_harness(name: str, arguments, ctx: ToolContext, repeated: bool) -> str:
56
+ """Ce que le harness fait de la demande du modèle, en une phrase (étape 17).
57
+
58
+ Seulement pour l'affichage : on rend visible le rôle du harness entre
59
+ le modèle, qui demande, et l'outil, qui exécute.
60
+ """
61
+ if repeated:
62
+ return "appel identique au précédent : pas exécuté, le modèle est prévenu"
63
+ if name not in TOOLS:
64
+ return "outil inconnu : l'erreur est renvoyée au modèle"
65
+ if name == "edit_file":
66
+ return "vérifie le chemin, montre le diff et demande ta confirmation"
67
+ if name == "run_command":
68
+ command = arguments.get("command", "") if isinstance(arguments, dict) else ""
69
+ if is_allowed_command(command, ctx.allowed_commands):
70
+ return "commande autorisée : lancée sans confirmation"
71
+ return "demande ta confirmation avant de lancer la commande"
72
+ if name == "load_skill":
73
+ return "vérifie que la skill existe et la charge"
74
+ return f"vérifie le chemin et exécute {name}"
75
+
76
+
77
+ def run_turn(chat: ChatFunction, messages: list[dict], ctx: ToolContext, max_tours: int = MAX_TOURS) -> str:
78
+ """Traite le dernier message utilisateur et renvoie la réponse finale.
79
+
80
+ `messages` est modifiée sur place : on y ajoute les messages de
81
+ l'assistant et les résultats des outils, pour garder tout l'historique.
82
+ L'affichage montre chaque tour : le modèle demande, le harness décide,
83
+ l'outil répond.
84
+ """
85
+ previous_call = None
86
+ for tour in range(1, max_tours + 1):
87
+ print(ui.tour_header(tour))
88
+ reply = chat(messages, tool_schemas())
89
+ # 1) On garde le message de l'assistant TEL QUEL, avec ses tool_calls :
90
+ # au prochain appel, le modèle doit voir ce qu'il a demandé.
91
+ messages.append(reply)
92
+
93
+ tool_calls = reply.get("tool_calls") or []
94
+ if not tool_calls:
95
+ print(ui.role_line("modèle", "répond sans demander d'outil : fin de la boucle"))
96
+ print(ui.answer_header(tour))
97
+ return reply.get("content", "")
98
+
99
+ for call in tool_calls:
100
+ name = call["function"]["name"]
101
+ arguments = call["function"].get("arguments", {})
102
+ print(ui.model_asks(name, arguments))
103
+
104
+ # Détection de boucle : le même appel deux fois de suite ne
105
+ # donnera pas un résultat différent, on le signale au modèle.
106
+ signature = (name, json.dumps(arguments, sort_keys=True, ensure_ascii=False))
107
+ repeated = signature == previous_call
108
+ print(ui.role_line("harness", describe_harness(name, arguments, ctx, repeated)))
109
+ if repeated:
110
+ result = (
111
+ f"Erreur : tu viens déjà d'appeler {name} avec exactement ces arguments. "
112
+ "Le résultat sera le même : change d'approche ou donne ta réponse finale."
113
+ )
114
+ else:
115
+ result = execute_tool(name, arguments, ctx)
116
+ previous_call = signature
117
+
118
+ shortened = truncate(result, MAX_OUTPUT_CHARS)
119
+ print(ui.tool_result(shortened, truncated=len(shortened) < len(result)))
120
+ # 2) Puis le résultat de l'outil, avec le rôle "tool".
121
+ messages.append({"role": "tool", "tool_name": name, "content": shortened})
122
+
123
+ print(ui.role_line("harness", f"limite de {max_tours} tours atteinte : la boucle s'arrête", ui.RED))
124
+ return f"Arrêt : limite de {max_tours} tours atteinte sans réponse finale."
llamagent/cli.py ADDED
@@ -0,0 +1,119 @@
1
+ """Point d'entrée de la ligne de commande : `llamagent`.
2
+
3
+ llamagent # mode conversation dans le dossier courant
4
+ llamagent "corrige le bug..." # une seule demande, puis on quitte
5
+ llamagent -m qwen3.5:9b -t 0.5 # options : voir llamagent --help
6
+
7
+ C'est ici qu'on assemble le harness : configuration, session (prompt
8
+ système, outils, garde-fous), commandes /..., puis la boucle agentique.
9
+ Tout l'affichage passe par ui.py (étape 17).
10
+ """
11
+
12
+ import argparse
13
+ import os
14
+ from importlib.metadata import version
15
+
16
+ import requests
17
+
18
+ from llamagent import ui
19
+ from llamagent.commands import execute_command
20
+ from llamagent.config import PROJECT_DIR_NAME, load_config
21
+ from llamagent.context import compact, needs_compaction
22
+ from llamagent.history import find_session, resume_session, save_session
23
+ from llamagent.session import Session, create_session, find_mentions, load_project_instructions
24
+ from llamagent.tools import available_skills
25
+
26
+
27
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
28
+ parser = argparse.ArgumentParser(prog="llamagent", description="Un agent de code minimal autour d'Ollama.")
29
+ parser.add_argument("demande", nargs="*", help="demande à traiter avant de quitter (sinon : mode conversation)")
30
+ parser.add_argument("-m", "--model", help="modèle Ollama, ex. qwen3.5:4b")
31
+ parser.add_argument("-t", "--temperature", type=float, help="température, ex. 0.2")
32
+ parser.add_argument("--num-ctx", type=int, help="taille du contexte en tokens, ex. 8192")
33
+ parser.add_argument("--url", help="adresse du serveur Ollama")
34
+ parser.add_argument("-c", "--continue", dest="resume", action="store_true", help="reprendre la dernière conversation du projet")
35
+ parser.add_argument("--version", action="version", version=f"llamagent {version('llamagent')}")
36
+ return parser.parse_args(argv)
37
+
38
+
39
+ def context_status(session: Session) -> str:
40
+ """La jauge de contexte affichée sous chaque réponse."""
41
+ c = session.config
42
+ return ui.context_gauge(c.model, session.context_tokens, c.num_ctx, session.context_estimated)
43
+
44
+
45
+ def handle_request(session: Session, text: str) -> None:
46
+ """Envoie une demande à l'agent et affiche sa réponse."""
47
+ for path in find_mentions(text):
48
+ print(ui.info(f"@{path} joint au message"))
49
+ try:
50
+ answer = session.ask(text)
51
+ except requests.RequestException as error:
52
+ print(ui.error(f"Erreur de communication avec Ollama : {error}"))
53
+ return
54
+ print(ui.answer(answer))
55
+ print("\n" + context_status(session))
56
+ # Étape 16 : entre deux demandes, on compacte si le contexte est presque plein.
57
+ if needs_compaction(session):
58
+ print(ui.warning("Contexte presque plein : compactage automatique de la conversation..."))
59
+ try:
60
+ compact(session)
61
+ print(context_status(session))
62
+ except requests.RequestException as error:
63
+ print(ui.error(f"Compactage impossible : {error}"))
64
+ # Étape 15 : la conversation est sauvegardée après chaque demande.
65
+ save_session(session)
66
+
67
+
68
+ def project_summary(session: Session) -> str:
69
+ """Une ligne qui résume ce que le projet apporte : AGENTS.md, skills, réglages."""
70
+ workdir = session.config.workdir
71
+ has_instructions = bool(load_project_instructions(workdir))
72
+ parts = ["AGENTS.md"] if has_instructions else []
73
+ parts.append(f"{len(available_skills(session.ctx.skills_dirs))} skills")
74
+ if (workdir / PROJECT_DIR_NAME / "config.toml").is_file():
75
+ parts.append("config.toml")
76
+ if not has_instructions:
77
+ parts.append("/init pour préparer le projet")
78
+ return ui.symbol(" · ", " - ").join(parts)
79
+
80
+
81
+ def main() -> None:
82
+ if os.name == "nt":
83
+ os.system("") # active les codes ANSI (couleurs) dans les consoles Windows
84
+
85
+ args = parse_args()
86
+ config = load_config({"model": args.model, "temperature": args.temperature, "num_ctx": args.num_ctx, "url": args.url})
87
+ session = create_session(config, confirm=ui.confirm)
88
+ session.waiting = ui.thinking # animation pendant l'attente du modèle
89
+
90
+ rows = [("modèle", config.model), ("serveur", config.ollama_url), ("dossier", str(config.workdir)), ("projet", project_summary(session))]
91
+ if args.resume:
92
+ data = find_session(config.workdir, "1")
93
+ if data is None:
94
+ rows.append(("reprise", "aucune conversation sauvegardée : nouvelle conversation"))
95
+ else:
96
+ resume_session(session, data)
97
+ rows.append(("reprise", f"{data.get('title', '')} ({len(data['messages'])} messages)"))
98
+ print(ui.banner(rows))
99
+
100
+ try:
101
+ # Mode « une seule demande » : la demande est passée en argument.
102
+ if args.demande:
103
+ handle_request(session, " ".join(args.demande))
104
+ return
105
+
106
+ while True:
107
+ text = input(ui.prompt()).strip()
108
+ if text in ("/quit", "/exit"):
109
+ break
110
+ if text.startswith("/"):
111
+ print(ui.indent(execute_command(session, text)))
112
+ elif text:
113
+ handle_request(session, text)
114
+ except (KeyboardInterrupt, EOFError):
115
+ print("\n" + ui.style(" Au revoir.", ui.GREY))
116
+
117
+
118
+ if __name__ == "__main__":
119
+ main()
llamagent/commands.py ADDED
@@ -0,0 +1,250 @@
1
+ """Les commandes /... tapées par l'utilisateur (et non par le modèle).
2
+
3
+ Même principe que le registre d'outils : un dictionnaire
4
+ nom -> (fonction, aide). Ajouter une commande ne touche ni à la boucle
5
+ ni à cli.py. Chaque fonction reçoit la session et le texte tapé après la
6
+ commande, et renvoie le texte à afficher.
7
+ """
8
+
9
+ from typing import Callable
10
+
11
+ import requests
12
+
13
+ from llamagent import llm
14
+ from llamagent.context import compact, context_report
15
+ from llamagent.config import PROJECT_DIR_NAME, PROJECT_INSTRUCTIONS
16
+ from llamagent.guards import SHELL_OPERATORS, GuardError
17
+ from llamagent.history import delete_session, find_session, list_sessions, resume_session, save_session
18
+ from llamagent.session import Session
19
+ from llamagent.tools import available_skills, load_skill
20
+
21
+
22
+ def cmd_help(session: Session, arg: str) -> str:
23
+ lines = [f"{name:<9} {description}" for name, (_, description) in COMMANDS.items()]
24
+ return "\n".join(lines + [f"{'/quit':<9} quitter", "", "@chemin dans une demande : joint le contenu du fichier."])
25
+
26
+
27
+ # Fichiers créés par /init dans le projet : chemin relatif -> contenu.
28
+ INIT_FILES = {
29
+ PROJECT_INSTRUCTIONS: """# Instructions du projet
30
+
31
+ - But du projet : ...
32
+ - Lancer le projet : ...
33
+ - Lancer les tests : python -m pytest -q
34
+ - Conventions de code : ...
35
+ - Fichiers ou dossiers à ne jamais modifier : ...
36
+ """,
37
+ f"{PROJECT_DIR_NAME}/config.toml": """# Réglages de llamagent pour ce projet (ils remplacent ~/.llamagent/config.toml).
38
+ # Retire le # devant une ligne pour l'activer.
39
+ # model = "qwen3.5:4b"
40
+ # temperature = 0.2
41
+ # num_ctx = 8192
42
+ # allowed_commands = ["python -m pytest", "git status", "git diff"]
43
+ """,
44
+ # Les conversations sauvegardées contiennent le contenu des fichiers lus :
45
+ # elles ne doivent pas finir dans le dépôt git du projet.
46
+ f"{PROJECT_DIR_NAME}/.gitignore": "sessions/\n",
47
+ f"{PROJECT_DIR_NAME}/skills/LISEZMOI.txt": """Skills de ce projet : un fichier .md par skill, par exemple deployer.md.
48
+
49
+ Format :
50
+
51
+ ---
52
+ description: Une ligne qui dit quand utiliser cette skill.
53
+ ---
54
+ # Titre
55
+
56
+ 1. Première étape de la méthode.
57
+ 2. ...
58
+
59
+ Le prompt système ne contient que le nom et la description de chaque skill.
60
+ Le modèle charge le contenu avec load_skill quand la demande correspond.
61
+ Une skill de ce dossier remplace une skill globale du même nom.
62
+ Ce fichier .txt n'est pas une skill : il est ignoré.
63
+ """,
64
+ }
65
+
66
+
67
+ def cmd_init(session: Session, arg: str) -> str:
68
+ workdir = session.config.workdir
69
+ # On ne remplace jamais un fichier existant : seuls les manquants sont créés.
70
+ missing = [rel for rel in INIT_FILES if not (workdir / rel).exists()]
71
+ if not missing:
72
+ return "Ce projet est déjà initialisé : rien à créer."
73
+ listing = "\n".join(f" {rel}" for rel in missing)
74
+ if not session.ctx.confirm(f"Fichiers à créer :\n{listing}\nLes créer dans {workdir} ?"):
75
+ return "Rien n'a été créé."
76
+ for rel in missing:
77
+ path = workdir / rel
78
+ path.parent.mkdir(parents=True, exist_ok=True)
79
+ path.write_text(INIT_FILES[rel], encoding="utf-8")
80
+ # Le prompt système est reconstruit pour tenir compte d'AGENTS.md,
81
+ # sans effacer la conversation en cours.
82
+ session.messages[0] = {"role": "system", "content": session.system_prompt()}
83
+ return f"Créé :\n{listing}\nComplète AGENTS.md, puis ajoute tes skills dans {PROJECT_DIR_NAME}/skills/."
84
+
85
+
86
+ def cmd_config(session: Session, arg: str) -> str:
87
+ c = session.config
88
+ return (
89
+ f"modèle : {c.model}\n"
90
+ f"serveur : {c.ollama_url}\n"
91
+ f"température : {c.temperature}\n"
92
+ f"contexte : {c.num_ctx} tokens\n"
93
+ f"dossier : {c.workdir}\n"
94
+ f"sans confirmer : {', '.join(session.ctx.allowed_commands) or 'aucune commande'}"
95
+ )
96
+
97
+
98
+ def cmd_models(session: Session, arg: str) -> str:
99
+ models = llm.list_models(session.config)
100
+ return "\n".join(("* " if name == session.config.model else " ") + name for name in models) or "(aucun modèle)"
101
+
102
+
103
+ def cmd_model(session: Session, arg: str) -> str:
104
+ if not arg:
105
+ return f"Modèle actuel : {session.config.model}"
106
+ models = llm.list_models(session.config)
107
+ # « qwen3.5 » désigne « qwen3.5:latest » pour Ollama.
108
+ name = arg if arg in models else f"{arg}:latest"
109
+ if name not in models:
110
+ return f"Modèle inconnu : {arg}. Tape /models pour voir les modèles installés."
111
+ session.update(model=name)
112
+ return f"Modèle : {name} (la conversation continue avec ce modèle)"
113
+
114
+
115
+ def cmd_temp(session: Session, arg: str) -> str:
116
+ if not arg:
117
+ return f"Température actuelle : {session.config.temperature}"
118
+ try:
119
+ value = float(arg)
120
+ except ValueError:
121
+ return "Usage : /temp 0.2"
122
+ if not 0 <= value <= 2:
123
+ return "La température doit être comprise entre 0 et 2."
124
+ session.update(temperature=value)
125
+ return f"Température : {value}"
126
+
127
+
128
+ def cmd_ctx(session: Session, arg: str) -> str:
129
+ if not arg:
130
+ return f"Contexte : {session.config.num_ctx} tokens"
131
+ try:
132
+ value = int(arg)
133
+ except ValueError:
134
+ return "Usage : /ctx 8192"
135
+ if not 2048 <= value <= 131072:
136
+ return "Le contexte doit être compris entre 2048 et 131072 tokens."
137
+ session.update(num_ctx=value)
138
+ return f"Contexte : {value} tokens (plus de contexte = plus de VRAM utilisée)"
139
+
140
+
141
+ def cmd_skills(session: Session, arg: str) -> str:
142
+ skills = available_skills(session.ctx.skills_dirs)
143
+ return "\n".join(f"{name} : {description}" for name, description in skills.items()) or "(aucune skill)"
144
+
145
+
146
+ def cmd_skill(session: Session, arg: str) -> str:
147
+ if not arg:
148
+ return "Usage : /skill nom (voir /skills)"
149
+ try:
150
+ content = load_skill(session.ctx, arg)
151
+ except GuardError as error:
152
+ return str(error)
153
+ # Ajoutée à la conversation : le modèle la verra dès la prochaine demande.
154
+ session.messages.append({"role": "user", "content": f"Pour la suite, suis les instructions de la skill « {arg} » :\n\n{content}"})
155
+ return f"Skill {arg} chargée dans la conversation."
156
+
157
+
158
+ def cmd_clear(session: Session, arg: str) -> str:
159
+ session.reset()
160
+ return "Conversation effacée. Prompt système, skills et AGENTS.md relus."
161
+
162
+
163
+ def cmd_sessions(session: Session, arg: str) -> str:
164
+ sessions = list_sessions(session.config.workdir)
165
+ if not sessions:
166
+ return "Aucune conversation sauvegardée dans ce projet."
167
+ lines = []
168
+ for number, data in enumerate(sessions, start=1):
169
+ current = "*" if data["id"] == session.session_id else " "
170
+ date = data.get("updated", "?").replace("T", " ")[:16]
171
+ lines.append(f"{current}{number:>3}. {date} {len(data.get('messages', [])):>3} messages {data.get('title', '')}")
172
+ return "\n".join(lines + ["", "/resume <numéro> pour reprendre, /delete <numéro> pour supprimer."])
173
+
174
+
175
+ def cmd_resume(session: Session, arg: str) -> str:
176
+ # Sans argument : la conversation la plus récente.
177
+ data = find_session(session.config.workdir, arg or "1")
178
+ if data is None:
179
+ return f"Conversation introuvable : {arg}. Tape /sessions pour la liste."
180
+ resume_session(session, data)
181
+ return f"Conversation reprise : {data.get('title', '')} ({len(data['messages'])} messages)."
182
+
183
+
184
+ def cmd_delete(session: Session, arg: str) -> str:
185
+ if not arg:
186
+ return "Usage : /delete <numéro> (voir /sessions)"
187
+ data = find_session(session.config.workdir, arg)
188
+ if data is None:
189
+ return f"Conversation introuvable : {arg}. Tape /sessions pour la liste."
190
+ if not session.ctx.confirm(f"Supprimer la conversation « {data.get('title', '')} » ?"):
191
+ return "Rien n'a été supprimé."
192
+ delete_session(data)
193
+ if data["id"] == session.session_id:
194
+ session.session_id = None # la conversation en cours sera sauvegardée dans un nouveau fichier
195
+ return f"Conversation supprimée : {data.get('title', '')}"
196
+
197
+
198
+ def cmd_context(session: Session, arg: str) -> str:
199
+ return context_report(session)
200
+
201
+
202
+ def cmd_compact(session: Session, arg: str) -> str:
203
+ if len(session.messages) <= 1:
204
+ return "Rien à compacter : la conversation est vide."
205
+ summary = compact(session)
206
+ save_session(session) # le fichier sauvegardé contient désormais le résumé
207
+ return f"Conversation compactée. Le modèle repart de ce résumé :\n\n{summary}"
208
+
209
+
210
+ def cmd_allow(session: Session, arg: str) -> str:
211
+ allowed = session.ctx.allowed_commands
212
+ if not arg:
213
+ return "Commandes lancées sans confirmation : " + (", ".join(allowed) or "aucune")
214
+ if any(operator in arg for operator in SHELL_OPERATORS):
215
+ return "Refusé : une commande autorisée ne doit contenir aucun opérateur shell (& | ; > < ...)."
216
+ allowed.append(arg)
217
+ return f"« {arg} » sera lancée sans confirmation jusqu'à la fin de la session."
218
+
219
+
220
+ # nom de la commande -> (fonction, aide affichée par /help)
221
+ COMMANDS: dict[str, tuple[Callable[[Session, str], str], str]] = {
222
+ "/help": (cmd_help, "liste des commandes"),
223
+ "/init": (cmd_init, "préparer le projet : AGENTS.md et dossier .llamagent/"),
224
+ "/config": (cmd_config, "réglages actuels"),
225
+ "/models": (cmd_models, "modèles installés sur le serveur Ollama"),
226
+ "/model": (cmd_model, "changer de modèle : /model qwen3.5:4b"),
227
+ "/temp": (cmd_temp, "changer la température : /temp 0.5"),
228
+ "/ctx": (cmd_ctx, "changer la taille du contexte : /ctx 16384"),
229
+ "/skills": (cmd_skills, "skills disponibles"),
230
+ "/skill": (cmd_skill, "charger une skill : /skill corriger-un-bug"),
231
+ "/clear": (cmd_clear, "effacer la conversation"),
232
+ "/context": (cmd_context, "ce qui occupe le contexte"),
233
+ "/compact": (cmd_compact, "résumer la conversation pour libérer du contexte"),
234
+ "/sessions": (cmd_sessions, "conversations sauvegardées dans ce projet"),
235
+ "/resume": (cmd_resume, "reprendre une conversation : /resume 2 (sans numéro : la dernière)"),
236
+ "/delete": (cmd_delete, "supprimer une conversation : /delete 2"),
237
+ "/allow": (cmd_allow, "lancer une commande sans confirmation : /allow python -m pytest"),
238
+ }
239
+
240
+
241
+ def execute_command(session: Session, line: str) -> str:
242
+ """Exécute une ligne comme « /temp 0.5 » et renvoie le texte à afficher."""
243
+ name, _, arg = line.strip().partition(" ")
244
+ if name not in COMMANDS:
245
+ return f"Commande inconnue : {name}. Tape /help."
246
+ function, _ = COMMANDS[name]
247
+ try:
248
+ return function(session, arg.strip())
249
+ except requests.RequestException as error:
250
+ return f"Erreur de communication avec Ollama : {error}"
llamagent/config.py ADDED
@@ -0,0 +1,123 @@
1
+ """Configuration de llamagent.
2
+
3
+ Les réglages viennent de plusieurs sources. Chacune remplace la précédente :
4
+ 1. les valeurs par défaut ci-dessous ;
5
+ 2. le fichier global ~/.llamagent/config.toml ;
6
+ 3. le fichier du projet .llamagent/config.toml (dans le dossier de travail) ;
7
+ 4. les variables d'environnement OLLAMA_URL et OLLAMA_MODEL ;
8
+ 5. les options de la ligne de commande (--model, --temperature...).
9
+ Les limites des garde-fous, elles, sont fixées ici.
10
+ """
11
+
12
+ import os
13
+ import tomllib
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ # Valeurs par défaut : Ollama sur la machine elle-même (son adresse standard),
18
+ # et un modèle 4B qui tient dans 6 Go de VRAM. Un Ollama installé ailleurs
19
+ # (sur un NAS par exemple) se règle dans ~/.llamagent/config.toml.
20
+ DEFAULT_OLLAMA_URL = "http://localhost:11434"
21
+ DEFAULT_OLLAMA_MODEL = "qwen3.5:4b"
22
+
23
+ # Température basse : on veut un agent prévisible, pas créatif.
24
+ TEMPERATURE = 0.2
25
+ # Taille de la fenêtre de contexte (en tokens) demandée à Ollama.
26
+ # 8192 est un bon compromis pour un modèle 4B sur une carte de 6 Go.
27
+ NUM_CTX = 8192
28
+ # Délai maximal d'une requête à Ollama, en secondes. Le premier appel est
29
+ # lent car Ollama doit charger le modèle en VRAM.
30
+ REQUEST_TIMEOUT = 120
31
+
32
+ # Garde-fous (étape 6).
33
+ MAX_TOURS = 15 # nombre maximal d'appels au LLM pour une même demande
34
+ COMMAND_TIMEOUT = 30 # durée maximale d'une commande lancée par run_command (s)
35
+ MAX_OUTPUT_CHARS = 4000 # taille maximale d'un résultat d'outil (~1000 tokens)
36
+ # Compactage automatique quand le contexte est rempli à 80 % (étape 16).
37
+ COMPACT_THRESHOLD = 0.8
38
+
39
+ # prompts/ et skills/ font partie du paquet (étape 18) : ils sont à côté de
40
+ # ce fichier, dans src/llamagent/, et donc installés avec le code, que
41
+ # llamagent vienne du dépôt ou de PyPI.
42
+ PACKAGE_DIR = Path(__file__).resolve().parent
43
+ PROMPTS_DIR = PACKAGE_DIR / "prompts"
44
+ SKILLS_DIR = PACKAGE_DIR / "skills"
45
+
46
+ # Réglages propres à chaque projet (étape 9) : un dossier .llamagent/ dans
47
+ # le dossier de travail, et un fichier AGENTS.md à sa racine.
48
+ PROJECT_DIR_NAME = ".llamagent"
49
+ PROJECT_INSTRUCTIONS = "AGENTS.md"
50
+ # Fichier de réglages global de l'utilisateur (étape 12).
51
+ USER_CONFIG = Path.home() / PROJECT_DIR_NAME / "config.toml"
52
+ # Les seules clés acceptées dans un fichier config.toml.
53
+ SETTINGS_KEYS = {"url", "model", "temperature", "num_ctx", "allowed_commands"}
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class Config:
58
+ """Réglages de la session. Pour en changer un : dataclasses.replace()."""
59
+
60
+ ollama_url: str
61
+ model: str
62
+ workdir: Path # le seul dossier où l'agent a le droit de lire et d'écrire
63
+ temperature: float = TEMPERATURE
64
+ num_ctx: int = NUM_CTX
65
+ # Commandes que run_command lance sans demander confirmation (étape 14).
66
+ allowed_commands: tuple[str, ...] = ()
67
+
68
+ @property
69
+ def chat_url(self) -> str:
70
+ """URL de l'API native d'Ollama (et non la couche /v1 compatible OpenAI)."""
71
+ return f"{self.ollama_url}/api/chat"
72
+
73
+ def options(self) -> dict:
74
+ """Bloc `options` envoyé à Ollama à chaque appel."""
75
+ return {"temperature": self.temperature, "num_ctx": self.num_ctx}
76
+
77
+
78
+ def read_toml(path: Path) -> dict:
79
+ """Lit un fichier de réglages TOML. Renvoie {} s'il n'existe pas."""
80
+ if not path.is_file():
81
+ return {}
82
+ try:
83
+ settings = tomllib.loads(path.read_text(encoding="utf-8"))
84
+ except tomllib.TOMLDecodeError as error:
85
+ raise SystemExit(f"Fichier de réglages invalide : {path}\n{error}")
86
+ # Une faute de frappe (« temperatur ») serait sinon ignorée sans prévenir.
87
+ unknown = set(settings) - SETTINGS_KEYS
88
+ if unknown:
89
+ raise SystemExit(f"Réglage inconnu dans {path} : {', '.join(sorted(unknown))}")
90
+ return settings
91
+
92
+
93
+ def load_config(cli_options: dict | None = None) -> Config:
94
+ """Construit la configuration à partir de toutes les sources, dans l'ordre."""
95
+ # Par défaut, l'agent travaille dans le dossier où on le lance.
96
+ workdir = Path(os.environ.get("LLAMAGENT_WORKDIR", Path.cwd())).resolve()
97
+ # Un dossier de travail absent ferait échouer tous les outils plus tard,
98
+ # avec des erreurs obscures (WinError 267 sous Windows) : on s'arrête net.
99
+ if not workdir.is_dir():
100
+ raise SystemExit(f"Dossier de travail introuvable : {workdir} (vérifie LLAMAGENT_WORKDIR).")
101
+
102
+ settings = {"url": DEFAULT_OLLAMA_URL, "model": DEFAULT_OLLAMA_MODEL, "temperature": TEMPERATURE, "num_ctx": NUM_CTX}
103
+ settings.update(read_toml(USER_CONFIG))
104
+ settings.update(read_toml(workdir / PROJECT_DIR_NAME / "config.toml"))
105
+ if "OLLAMA_URL" in os.environ:
106
+ settings["url"] = os.environ["OLLAMA_URL"]
107
+ if "OLLAMA_MODEL" in os.environ:
108
+ settings["model"] = os.environ["OLLAMA_MODEL"]
109
+ # Options de la ligne de commande : seulement celles qui ont été données.
110
+ settings.update({key: value for key, value in (cli_options or {}).items() if value is not None})
111
+
112
+ try:
113
+ return Config(
114
+ # On retire un éventuel "/" final pour pouvoir écrire f"{url}/api/chat".
115
+ ollama_url=str(settings["url"]).rstrip("/"),
116
+ model=str(settings["model"]),
117
+ workdir=workdir,
118
+ temperature=float(settings["temperature"]),
119
+ num_ctx=int(settings["num_ctx"]),
120
+ allowed_commands=tuple(str(command) for command in settings.get("allowed_commands", ())),
121
+ )
122
+ except (TypeError, ValueError) as error: # ex. temperature = "chaud"
123
+ raise SystemExit(f"Réglage invalide : {error}")