Agente AI per analisi tecnica di codebase PHP legacy: architettura con Claude API e tool use

Agente AI per analisi tecnica di codebase PHP legacy: architettura con Claude API e tool use

Ho fatto girare per la prima volta l'agente completo sulla mia codebase di riferimento l'8 marzo 2026 su una workstation personale con AMD Ryzen 9 7950X3D, 128 GB RAM DDR5, 2 TB NVMe e Debian 12, ambiente Python 3.12 con Anthropic SDK 0.39 e Claude Sonnet 4.6 come motore. Il target era la sandbox da 200.000 righe Symfony 7.2 / PHP 8.3 che uso per tutti questi esperimenti - 1.400 classi, 380 controller, 94 entity Doctrine, 12 anni di storia git ricostruita, zero documentazione vera per rendere il test realistico. L'obiettivo del singolo run era produrre, in un'ora senza supervisione, un report di assessment tecnico del tipo che un senior consegnerebbe al cliente dopo una settimana di analisi: debito tecnico stimato, aree ad alta complessità, pattern anti-pattern ricorrenti, rischi di sicurezza evidenti, priorità suggerite. Dopo tre tentativi e una serie di aggiustamenti al prompt e ai tool, il quarto run ha prodotto un report di 14 pagine ragionato e strutturato, con 47 finding concreti ancorati a file e linee specifiche, in 53 minuti e 2,40 dollari di API. Il report non era perfetto - tre finding erano falsi allarme, quattro erano corretti ma di rilevanza trascurabile - ma il rapporto segnale-rumore era tale che un senior umano ci avrebbe messo 20-30 minuti a rivederlo invece di giorni a produrlo da zero. Questa era la prova che il pattern agent con tool use è una primitiva di produttività reale, non un demo da conferenza.

Perché un agente con tool use fa meglio di un chatbot che riceve il codice incollato?

La risposta breve è che un chatbot con il codice nel prompt è vincolato al context window del modello - centinaia di migliaia di token per Sonnet 4.6, ma comunque finiti e ingovernabili quando la codebase supera le 500k righe. Un agente con tool use non carica il codice a priori: chiama tool per leggere file, listare directory, cercare pattern, eseguire analisi statica. Il modello decide cosa serve in base al task e chiede solo quello. Il context effettivo usato in una sessione tipica del mio agente è 20-40k token anche su codebase da 200k righe, perché il modello fa retrieval just-in-time invece di ingoiare tutto.

La seconda ragione è la compositionalità. Con il codice nel prompt, il modello può solo parlare del codice. Con i tool, può agire: lanciare PHPStan su un file sospetto, contare i chiamanti di un metodo con grep, aprire il commit che ha introdotto una modifica. L'agente diventa un senior junior digitale che sa navigare la codebase nello stesso modo in cui lo farebbe un umano esperto, solo infinitamente più veloce. Questo è il pattern Tool Use della Anthropic API descritto nella documentazione ufficiale ed è il fondamento architetturale di tutta la famiglia di agent systems 2026.

La terza ragione è il cost control: pagare Claude per lavorare su 200.000 righe incollate nel prompt è proibitivo (prompt da 800k+ token per tutta la sessione). Con tool use, il modello paga solo per il subset di codice che ha effettivamente letto - spesso il 5-10% del totale - e ciascun tool call ha overhead dell'ordine di 1k token di input.

Se vuoi vedere come costruisco agent systems che fanno lavori di ingegneria reale su codebase senza sostituire il ragionamento senior, nel mio hub sullo sviluppo AI per aziende trovo articoli su MCP server custom, bot di code review, knowledge management, tutti con il filo conduttore di tool-use disciplinato e human in the loop sulle decisioni finali.

Step 1: definire i tool che l'agente può chiamare

Il primo design decision è la lista dei tool. Meno ce ne sono, meglio è: ogni tool aumenta la superficie di scelta del modello e il rischio di chiamate inutili. Per l'analisi codebase mi sono fermato a sette tool che coprono l'80% dei workflow ragionevoli.

TOOLS = [
    {
        "name": "list_dir",
        "description": "Lista i file e le subdirectory di un path relativo alla codebase root. Restituisce max 200 entry.",
        "input_schema": {
            "type": "object",
            "properties": {"path": {"type": "string"}},
            "required": ["path"],
        },
    },
    {
        "name": "read_file",
        "description": "Legge il contenuto di un file PHP/YAML/JSON. Massimo 800 linee per chiamata.",
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {"type": "string"},
                "start_line": {"type": "integer", "default": 1},
                "end_line": {"type": "integer", "default": 800},
            },
            "required": ["path"],
        },
    },
    {
        "name": "grep_code",
        "description": "Cerca pattern regex in tutti i file .php della codebase. Restituisce fino a 80 match con 2 righe di contesto.",
        "input_schema": {
            "type": "object",
            "properties": {"pattern": {"type": "string"}, "subdir": {"type": "string", "default": "src"}},
            "required": ["pattern"],
        },
    },
    {
        "name": "find_symbol",
        "description": "Trova definizione e chiamanti di una classe o funzione PHP. Usa AST analysis (nikic/php-parser).",
        "input_schema": {
            "type": "object",
            "properties": {"symbol_name": {"type": "string"}, "kind": {"type": "string", "enum": ["class", "function", "method"]}},
            "required": ["symbol_name", "kind"],
        },
    },
    {
        "name": "run_phpstan",
        "description": "Esegue PHPStan livello 8 su un file o directory. Restituisce gli errori raw.",
        "input_schema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]},
    },
    {
        "name": "git_log_for_file",
        "description": "Mostra gli ultimi 20 commit che hanno modificato un file, con timestamp e messaggio.",
        "input_schema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]},
    },
    {
        "name": "write_finding",
        "description": "Registra un finding del report in output strutturato. L'agente NON deve assumere che ogni finding venga accettato.",
        "input_schema": {
            "type": "object",
            "properties": {
                "severity": {"type": "string", "enum": ["critical", "major", "minor", "info"]},
                "category": {"type": "string"},
                "file": {"type": "string"},
                "line": {"type": "integer"},
                "description": {"type": "string", "maxLength": 600},
                "evidence": {"type": "string", "maxLength": 400},
            },
            "required": ["severity", "category", "file", "description"],
        },
    },
]

Il write_finding non è un tool che muta lo stato globale, è un output strutturato: l'agente lo chiama ogni volta che vuole aggiungere un finding al report finale. Questo pattern di usare un tool come canale di output strutturato è la mia soluzione preferita al problema "come ottengo JSON ben formato dal modello senza parser fragili": il modello stesso chiama il tool con i parametri, il framework Anthropic garantisce che rispettino lo schema, io ricevo dati tipati.

I tool di lettura hanno limiti espliciti (800 linee per file, 80 match per grep, 200 entry per listing) che costringono il modello a essere selettivo. Senza limiti, l'agente finisce a leggere tutto e i token esplodono.

Step 2: il main loop di tool use con tracciamento

Il main loop segue il pattern agentic standard: il modello risponde con una tool_use content block, il sistema esegue il tool, restituisce il risultato come tool_result, il modello risponde di nuovo. Si ripete finché il modello non restituisce una risposta di tipo end_turn. A ogni ciclo, l'agente pensa (il thinking di Claude Sonnet 4.6 è visibile nel content block thinking e serve tenerlo per coerenza) poi decide se chiamare altri tool o concludere.

from anthropic import Anthropic

client = Anthropic()

def run_agent(task: str, max_iterations: int = 40, cost_cap_usd: float = 5.0) -> dict:
    messages = [{"role": "user", "content": task}]
    total_cost_usd = 0.0
    findings = []
    iteration = 0

    while iteration < max_iterations:
        iteration += 1
        response = client.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=4096,
            system=AGENT_SYSTEM_PROMPT,
            tools=TOOLS,
            messages=messages,
        )

        total_cost_usd += compute_cost(response.usage, "claude-sonnet-4-6")
        if total_cost_usd > cost_cap_usd:
            return {"status": "cost_cap_exceeded", "findings": findings, "cost": total_cost_usd}

        if response.stop_reason == "end_turn":
            return {"status": "completed", "findings": findings, "iterations": iteration, "cost": total_cost_usd}

        # Esegui tutti i tool_use del turno
        tool_results = []
        for block in response.content:
            if block.type == "tool_use":
                result = execute_tool(block.name, block.input)
                if block.name == "write_finding":
                    findings.append(block.input)
                tool_results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": result,
                })

        messages.append({"role": "assistant", "content": response.content})
        messages.append({"role": "user", "content": tool_results})

    return {"status": "max_iterations_reached", "findings": findings, "cost": total_cost_usd}

Due dettagli operativi importanti. Il cost_cap_usd: 5.0 è il kill switch che discuto nello step 6 - senza questo, un bug del loop può consumare 50 dollari di API in dieci minuti. Il max_iterations: 40 è un secondo kill switch indipendente - se l'agente entra in un ciclo di tool call che non progredisce (raro ma successo), il loop si ferma comunque.

Step 3: il system prompt specializzato per assessment legacy

Il system prompt è il punto dove sta la disciplina dell'agente. Quello che uso oggi è il risultato di 12 iterazioni, con tre principi non negoziabili. Primo: output solo via write_finding, non narrativa libera. Se l'agente scrive un finding come prosa nel content testo, non entra nel report - il report vive solo nei chiamate a write_finding. Secondo: ancorare ogni finding a un file e a una linea. Un finding senza riferimento preciso è rumore. Terzo: non ripetere finding già registrati. Il modello riceve, in ogni turno, la lista dei finding già emessi, e ha istruzione di evitare duplicati.

SYSTEM PROMPT (estratto):
Sei un senior software engineer che fa assessment tecnico di codebase PHP legacy.

Il tuo task è produrre un report con finding concreti su: debito tecnico,
complessità, security, testabilità, performance. Ogni finding deve essere
registrato via tool `write_finding` - non scrivere mai il report come prosa.

Regole NON negoziabili:
1. Ogni finding DEVE avere file e line number specifici.
2. NON creare finding su codice che non hai letto esplicitamente via read_file.
3. NON creare più di 50 finding totali. Qualità > quantità.
4. NON ripetere finding già registrati (te li mostro in user message ad ogni turno).
5. Se non hai informazioni sufficienti per un finding certo, NON crearlo.
   Meglio un report più corto e accurato che uno lungo e speculativo.

Quando pensi di aver coperto le aree principali, concludi con un resoconto breve
e termina. Max 40 iterazioni di tool use.

La regola "NON creare finding su codice che non hai letto esplicitamente" è quella che taglia il 70% dei falsi allarmi. Senza di essa, l'agente inferisce problemi da nomi di file o descrizioni di directory listing e genera finding plausibili ma infondati.

Step 4: gestione del contesto lungo e pruning intelligente

Dopo 15-25 iterazioni il messages array accumula migliaia di tool_result e l'input della prossima chiamata supera i 200k token. Questo fa crescere il costo e rallenta le risposte. La strategia di pruning che uso è mantenere in memoria gli ultimi 5 risultati pieni e sostituire i più vecchi con un riassunto di una riga.

def prune_messages(messages: list, keep_full: int = 5) -> list:
    pruned = []
    for i, msg in enumerate(messages):
        if msg["role"] == "user" and isinstance(msg["content"], list):
            tool_results = [b for b in msg["content"] if b.get("type") == "tool_result"]
            if tool_results and i < len(messages) - (keep_full * 2):
                pruned.append({
                    "role": "user",
                    "content": [
                        {
                            "type": "tool_result",
                            "tool_use_id": tr["tool_use_id"],
                            "content": f"[pruned: tool executed, result was {len(tr['content'])} chars]",
                        }
                        for tr in tool_results
                    ],
                })
                continue
        pruned.append(msg)
    return pruned

Ogni 5 iterazioni il loop chiama prune_messages prima di inviare la prossima request. Il modello continua a vedere i suoi ultimi cinque tool-result per intero, e i più vecchi come placeholder - il che va bene perché le decisioni del momento sono ancorate al passato recente, non al passato remoto dei 20 turni prima. Il risultato misurato: input medio scende da 85k token a 30k token a regime, costo per iterazione taglia 2x.

Questo è un pattern simile a quello del knowledge management con memoria persistente che ho descritto: l'attenzione al passato deve essere selettiva, perché tenere tutto è inefficiente e distraente.

Step 5: output strutturato del report finale

I finding raccolti via write_finding sono già strutturati - severity, category, file, line, description, evidence. Il post-processing finale raggruppa per severity e category, calcola statistiche aggregate (numero di finding per file più "caldo", distribuzione di severity), e genera un report markdown leggibile.

def render_report(findings: list, task: str, duration_s: int, cost_usd: float) -> str:
    by_severity = defaultdict(list)
    for f in findings:
        by_severity[f["severity"]].append(f)

    lines = [
        "# Assessment tecnico automatizzato",
        f"",
        f"**Task:** {task}",
        f"**Generato:** {datetime.now().isoformat()}",
        f"**Durata:** {duration_s}s | **Cost:** ${cost_usd:.2f}",
        f"**Finding totali:** {len(findings)} (critical: {len(by_severity['critical'])}, major: {len(by_severity['major'])}, minor: {len(by_severity['minor'])})",
        f"",
    ]
    for severity in ["critical", "major", "minor", "info"]:
        if by_severity[severity]:
            lines.append(f"## Finding {severity}")
            lines.append("")
            for f in by_severity[severity]:
                lines.append(f"### {f['category']} in `{f['file']}:{f.get('line', '?')}`")
                lines.append("")
                lines.append(f["description"])
                if f.get("evidence"):
                    lines.append(f"\n> {f['evidence']}")
                lines.append("")
    return "\n".join(lines)

Il report markdown finisce in un file timestamp-ato. La review umana del report richiede 15-30 minuti per una codebase da 200k righe - contro le ore o i giorni che richiederebbe produrlo da zero. Gli 8-12% di finding falsi che incontro in media sono il costo che accetto per il fattore 10-20x di velocità.

Step 6: cost cap, kill switch, e audit trail

Un agent che consuma API budget senza vincoli è esattamente l'esempio di LLM10 Unbounded Consumption della Top 10 OWASP. Il cost_cap_usd: 5.0 del loop è il primo vincolo. Il max_iterations: 40 è il secondo. Un terzo vincolo che ho aggiunto dopo un incidente di retry storm è un rate limit sulle chiamate per minuto - non più di 10 iterazioni per minuto. Se l'agente esegue tool che ritornano velocemente e si fa prendere la mano, il rate limit lo rallenta artificialmente.

def run_agent_with_rate_limit(task: str, ...):
    last_iteration_time = 0
    iteration_count_in_window = 0
    window_start = time.time()

    while ...:
        if time.time() - window_start > 60:
            window_start = time.time()
            iteration_count_in_window = 0
        if iteration_count_in_window >= 10:
            sleep_time = 60 - (time.time() - window_start)
            time.sleep(max(0, sleep_time))
            continue
        iteration_count_in_window += 1
        # ... continua loop normale

L'audit trail persistito su PostgreSQL registra ogni tool call - timestamp, nome tool, input, output, token consumati, costo incrementale. Se domani qualcuno chiede "cosa ha fatto esattamente l'agente alle 14:32 di ieri?", la risposta è in una query SQL. Questo è il corrispettivo applicativo del principio di defense in depth che descrivo nell'articolo sulla difesa da prompt injection in agent systems: un agente che fa azioni senza trail non è monitorabile, un agente con ogni step persistito lo è.

Quando un agente custom non è la scelta giusta

Se la tua codebase ha meno di 50k righe, non serve un agente - un senior ci mette poche ore a leggerla tutta e produrre un report migliore. Se i tool che vuoi dare all'agente sono pochi e ben definiti (solo read_file e grep), può bastare una sequenza deterministica scripted invece di un agente che ragiona sui tool da chiamare. Se hai già a disposizione un MCP server ben configurato come ho descritto in altri articoli del mio hub sullo sviluppo AI, un agente generico come Claude Code può fare il lavoro dell'agente custom senza il costo di svilupparlo - il trade-off è meno controllo sul loop ma zero codice da mantenere.

Un agente custom con tool use dedicato si giustifica quando hai contemporaneamente: necessità di analisi ricorrenti su codebase multiple (non un one-shot), output strutturato con schema preciso che vuoi garantire, requirement di cost cap e audit trail che gli agent commerciali non offrono, e uno o più tool specializzati del tuo dominio (database interni, API proprietarie, convention aziendali specifiche) che non sono disponibili via MCP pubblici. In quel punto, investire 2-3 settimane di sviluppo nell'agente paga in mesi di analisi automatizzate e di assessment consistenti ripetibili.

La differenza fra un assessment fatto a mano da un senior in una settimana e uno fatto da un agente in un'ora non è qualitativa - è quantitativa. L'agente non è meglio del senior, è più veloce e ripetibile. Il senior che riceve il report dell'agente e lo rivede in 30 minuti produce un output migliore di entrambi, usando l'ora che avrebbe speso a scrivere la prima bozza per aggiungere il ragionamento architetturale che solo un umano con esperienza può dare. Questo è il modello di integrazione che funziona: l'agente come bozzista infaticabile che prepara la pista per il pensiero umano, non come sostituto. Le PMI italiane che capiscono questa distinzione stanno trovando leva vera nel 2026. Quelle che cercano l'agente magico che sostituisce lo sviluppatore senior stanno finendo nei 40% di progetti agentic AI cancellati entro il 2027 che Gartner ha previsto nel suo press release di giugno 2025 - e non perché la tecnologia non funziona, perché la premessa era sbagliata.

Se stai valutando un agente AI custom per workflow di analisi, audit o assessment sulla tua codebase - e vuoi capire dove il design del tool-set, del cost cap, e dell'audit trail fa la differenza fra successo e incidente, il modulo di preventivo gratuito ti dà una prima lettura in 7 domande, 2 minuti. Ti dico se il tuo progetto rientra nelle cose che so fare bene e, se il caso richiede un profilo diverso, te lo dico e ti indico una direzione utile quando posso.

Ultima modifica: