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 +1 -0
- llamagent/agent.py +124 -0
- llamagent/cli.py +119 -0
- llamagent/commands.py +250 -0
- llamagent/config.py +123 -0
- llamagent/context.py +109 -0
- llamagent/guards.py +66 -0
- llamagent/history.py +82 -0
- llamagent/llm.py +49 -0
- llamagent/prompts/AGENT.md +22 -0
- llamagent/prompts/COMPACT.md +12 -0
- llamagent/session.py +147 -0
- llamagent/skills/corriger-un-bug.md +12 -0
- llamagent/skills/ecrire-tests-pytest.md +14 -0
- llamagent/tools.py +317 -0
- llamagent/ui.py +317 -0
- llamagent-0.1.0.dist-info/METADATA +147 -0
- llamagent-0.1.0.dist-info/RECORD +21 -0
- llamagent-0.1.0.dist-info/WHEEL +4 -0
- llamagent-0.1.0.dist-info/entry_points.txt +2 -0
- llamagent-0.1.0.dist-info/licenses/LICENSE +21 -0
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}")
|