Contenuti

Oltre il vibe coding: una pipeline multi-agent per convertire codice

Intro

Qualche settimana fa ho fatto una prova molto semplice: ho preso TinyDB, ho aperto un coding assistant e gli ho chiesto di fare la cosa più ovvia del mondo: convert this to Rust.

Il risultato, a prima vista, non era neppure male. Il codice sembrava plausibile, compilava, passava qualche test e dava proprio quella sensazione pericolosa del “direi che ci siamo”. Poi però, appena ho iniziato a guardarlo da vicino, sono saltati fuori i problemi veri: un deadlock in insert_multiple, corruzione silenziosa integer-to-float nelle operazioni di update, un panic path su regex non valide e circa il 40% delle feature del package originale semplicemente sparite.

Il problema non è che gli LLM “non sanno convertire codice”. È che la conversione di una codebase non banale non è un singolo task generativo. Non è “scrivi Rust al posto di Python”, ma “ricostruisci struttura, dipendenze, design intent, ordine di migrazione, invarianti, test e solo dopo genera codice”.

In altre parole: non sto contestando il modello in sé. Sto contestando la forma del lavoro che gli chiediamo.

Per questo ho provato un approccio diverso: invece di delegare tutto a un solo passaggio, ho arricchito prima la codebase con conoscenza strutturata e poi ho lasciato che un piccolo team di agenti specializzati usasse quella base per pianificare ed eseguire la conversione.

La tesi di questo articolo è semplice: su conversioni di codice non banali, il salto di qualità non arriva dal prompt più furbo, ma dalla scomposizione del problema e dall’uso di strumenti deterministici dove possibile.

Nel mio precedente articolo avevo già parlato di Spec-Driven Development. Qui provo a portare quello stesso ragionamento dentro un caso molto concreto: il porting Python-to-Rust di una libreria reale.

Perché la conversione single-shot si rompe

Negli ultimi mesi i coding agent sono migliorati parecchio. Su codebase piccole o su task molto delimitati, l’approccio “vibe coding” funziona meglio di quanto molti si aspettino.

Ma quando il target è una libreria vera, con più moduli, API pubblica, comportamento implicito e vincoli architetturali non banali, le crepe iniziano a vedersi in fretta.

Nei miei test, i fallimenti più ricorrenti sono stati questi:

  • Dipendenze allucinate: il modello inventa relazioni che non esistono oppure perde la topologia reale di import e call.
  • Planning confuso: helper, test, modulo core e dettagli secondari finiscono tutti sullo stesso piano.
  • Incoerenza cross-file: un tipo definito in un file non corrisponde più all’astrazione usata in un altro.
  • Spreco di token: il modello consuma contesto per riscoprire fatti strutturali che un parser potrebbe estrarre in modo deterministico.

La conversione di codice è un task composito. Alcune parti sono deterministiche, alcune sono architetturali, alcune sono esplorative e solo una parte è davvero generativa.

Trattare tutto come un unico prompt monolitico significa chiedere allo stesso modello di:

  1. capire la struttura della codebase
  2. inferire il design originale
  3. stimare le dipendenze
  4. decidere l’ordine di migrazione
  5. generare il codice
  6. verificare da solo se ciò che ha scritto è sensato

È troppo. Non perché il modello sia “scarso”, ma perché stiamo comprimendo in un solo passaggio un problema che, in pratica, è fatto di fasi diverse e con gradi di ambiguità molto diversi.

L’approccio: arricchire la codebase prima di convertire

La soluzione che ho provato non parte dal prompt. Parte dalla codebase.

Prima di chiedere a un LLM di generare codice nel linguaggio target, costruisco due layer di conoscenza:

  1. un database a grafo in Neo4j, che memorizza lo scheletro strutturale della codebase sorgente: moduli, classi, funzioni, metodi, variabili, import, call, inheritance e containment;
  2. un vector store in LanceDB, che contiene documentazione ed esempi della libreria, usati in modalità RAG.

Poi metto questi strumenti a disposizione di un team coordinato di agenti:


flowchart TB
    subgraph "Knowledge Layer"
        A[Python codebase] -->|AST parsing| B[Neo4j graph]
        D[PDF or Markdown docs] -->|Chunk + embed| E[LanceDB vector store]
    end

    subgraph "Agent Layer"
        O[Orchestrator] --> CA[Code Analyzer]
        O --> BA[Builder]
        O --> VA[Verifier]
        CA -->|Cypher queries| B
        BA -->|Semantic search| E
        BA -->|Code generation| F[Generated Rust code]
        VA -->|Compile + review| F
    end

    O -->|Conversion plan| BA
    VA -->|Revision feedback| BA

Il vantaggio non sta nell’avere “più agenti” in sé. Sta nel dare a ogni stage una responsabilità più stretta e un toolset più adatto.

Non sto dando al modello massima libertà. Sto restringendo il suo spazio decisionale nei punti in cui questa libertà produce più facilmente errori costosi.

AgentRuoloTool principaliPerché questa scelta funziona
OrchestratorCoordina il workflow e delega i taskFile tools, orchestrazione dei taskModello forte per planning e decomposizione
Code AnalyzerProduce un piano di conversioneTool Neo4j, query Cypher, complexity analysisUn modello più piccolo basta, perché legge dati strutturati
BuilderGenera i moduli RustVector search, code generationQui un coding model forte ripaga davvero
VerifierControlla correttezza e propone fixCompiler checks, semantic reviewLo stesso coding model, ma focalizzato sulla review

La scelta di design chiave è questa: non tutti i ruoli meritano lo stesso modello.

L’Analyzer non ha bisogno di un modello di frontiera se sta interrogando un grafo già costruito. Il Builder invece è esattamente il punto in cui vale la pena spendere di più, perché lì il task è davvero generativo.

I quattro step della pipeline

1. Costruzione del graph

Il primo step è un passaggio statico sull’AST (Abstract Syntax Tree) di ogni file .py del repository. Qui non entra in gioco nessun LLM.

È una fase deterministica, economica e veloce. Ovviamente l’analisi statica di Python ha limiti evidenti quando il comportamento è molto dinamico, ma per una codebase come TinyDB cattura gran parte della topologia che serve davvero alla fase di planning.

# kb_builder/python_graph_parser.py (simplified)
class PythonGraphParser:
    def parse_repository(self):
        python_files = list(self.repo_path.rglob("*.py"))
        for py_file in python_files:
            self._parse_file(py_file)
        return self.nodes, self.relationships


class ModuleVisitor(ast.NodeVisitor):
    def visit_ClassDef(self, node):
        class_id = f"class:{self.module_name}.{node.name}"
        bases = [self._get_name(base) for base in node.bases]

        self.parser._add_node(Node(
            id=class_id,
            type="Class",
            name=node.name,
            properties={
                "full_name": f"{self.module_name}.{node.name}",
                "bases": bases,
                "docstring": ast.get_docstring(node),
            },
        ))

        for base in node.bases:
            base_id = self.resolve_name(self._get_name(base), "class")
            self.parser._add_relationship(Relationship(
                source_id=class_id,
                target_id=base_id,
                type="INHERITS",
            ))

Il parser estrae sette tipi di nodi: Module, Class, Function, Method, Variable, GlobalVariable e ClassAttribute.

In più cattura relazioni strutturali come CONTAINS, IMPORTS, INHERITS, CALLS, DEFINES, USES e DECORATES.

Una volta terminato il parsing, il graph viene caricato in bulk dentro Neo4j:

uv run python kb_builder/build_python_graph.py \
    --repo-path /path/to/target/python/repo

2. Verifica e visualizzazione del graph

Una volta che il graph è in Neo4j, posso ispezionarlo in due modi:

  • con query mirate, per controlli precisi;
  • con una visualizzazione del grafo, per avere una mappa high-level della codebase.

Ecco due esempi semplici:

-- Find the most complex classes
MATCH (c:Class)
OPTIONAL MATCH (c)-[:DEFINES]->(m:Method)
OPTIONAL MATCH (c)-[:DEFINES]->(a:ClassAttribute)
WITH c, count(DISTINCT m) AS methods, count(DISTINCT a) AS attributes
WHERE methods + attributes > 5
RETURN c.name, c.full_name, methods, attributes
ORDER BY (methods + attributes) DESC
-- Trace call chains up to depth 3
MATCH path = (f1)-[:CALLS*1..3]->(f2)
WHERE (f1:Function OR f1:Method) AND (f2:Function OR f2:Method)
RETURN f1.full_name AS caller, f2.full_name AS callee, length(path) AS depth
LIMIT 25

Figura 1 - Vista high-level del graph TinyDB in Neo4j

Figura 2 - Zoom sulle relazioni all'interno del graph

Questo stage è utile ancora prima che la conversione parta davvero. Mostra quali moduli sono centrali, quali sono foglie e dove stanno i colli di bottiglia nelle dipendenze. Di conseguenza, pianificare un ordine di build realistico diventa molto più semplice.

3. Caricamento della documentazione nel vector store

Per generare codice sensato, il Builder non ha bisogno solo del source code. Ha bisogno anche di documentazione, esempi d’uso e dettagli API.

Qui entra in gioco il vector store. Il flusso è quello classico del RAG: carico documentazione PDF o Markdown, la divido in chunk, faccio embedding e salvo tutto in LanceDB.

# kb_builder/extract_text_from_pdf.py (key steps)
loader = PyPDFLoader(str(doc_path))
pages = loader.load()

text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=int(os.environ["SPLIT_CHUNK_SIZE"]),
    chunk_overlap=int(os.environ["SPLIT_CHUNK_OVERLAP"]),
)

chunks = text_splitter.split_documents(pages)
vectorstore.add_documents(chunks)

Nel caso di librerie pubbliche, Context7 spesso è anche meglio, perché fornisce documentazione curata e aggiornata senza tutta la complessità di una pipeline RAG fatta in casa.

Detto questo, il RAG locale con LanceDB o con un altro vector DB continua ad avere senso quando lavori su repository privati, framework interni o documentazione aziendale che non esiste su servizi pubblici.

4. Esecuzione della conversione

Con graph DB e documentazione al loro posto, posso lanciare la pipeline vera e propria:

bash deepagents.sh \
    --project tinydb \
    --target-language rust \
    --show-trace \
    --show-token-usage \
    --show-model-thinking \
    --write-run-report

Sotto il cofano, l’entrypoint run_conversion.py collega un Orchestrator e tre subagent usando LangChain DeepAgents:

# run_conversion.py (simplified)
subagents = [
    CompiledSubAgent(
        name="code_analyzer",
        description="Analyze the codebase and create a conversion plan.",
        runnable=analyzer.agent,
    ),
    CompiledSubAgent(
        name="builder_agent",
        description="Generate target-language code for a specific component.",
        runnable=builder.agent,
    ),
    CompiledSubAgent(
        name="verifier_agent",
        description="Verify generated code and suggest revisions.",
        runnable=verifier.agent,
    ),
]

agent = create_deep_agent(
    model=orchestrator_llm,
    system_prompt="You are a code conversion assistant...",
    subagents=subagents,
    backend=build_backend_factory(memory_root),
    name="orchestrator",
)

Ogni ruolo è configurabile in modo indipendente tramite variabili d’ambiente, così la pipeline può assegnare modelli diversi a job diversi:

# .env (example)
ORCHESTRATOR_LLM_PROVIDER=bedrock
ORCHESTRATOR_LLM_MODEL=global.anthropic.claude-opus-4-5-20251101-v1:0

ANALYZER_LLM_PROVIDER=bedrock
ANALYZER_LLM_MODEL=global.anthropic.claude-haiku-4-5-20251001-v1:0

BUILDER_LLM_PROVIDER=bedrock
BUILDER_LLM_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0

VERIFIER_LLM_PROVIDER=bedrock
VERIFIER_LLM_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0

Qualche dettaglio implementativo

L’Analyzer: planning a partire da Neo4j

L’Analyzer non legge direttamente i file Python. Dialoga con un server MCP che ho realizzato apposta per accedere a Neo4j. Il tool MCP espone operazioni mirate come describe_graph_structure, query_graph, get_code, get_class_hierarchy, analyze_complexity e get_module_dependencies.

# agents/code_analyzer.py
class CodeAnalyzer(BaseAgent):
    def __init__(self, config, *, enable_reasoning=False):
        tools_project = Path(__file__).resolve().parent.parent / "code_converter_tools"
        self.mcp_client = MultiServerMCPClient({
            "neo4j_tools": {
                "transport": "stdio",
                "command": "uv",
                "args": ["--directory", str(tools_project), "run", "neo4j_tools.py"],
            }
        })

Questo cambia parecchio il problema del planning. Invece di dire al modello “leggi e interpreta questo repository”, posso permettergli di fare domande molto più focalizzate, per esempio:

  • quali moduli sono foglie nel grafo delle dipendenze?
  • quali classi hanno la complessità strutturale più alta?
  • cosa eredita da Storage?
  • quali metodi chiamano insert o search?

Per l’Orchestrator è una differenza enorme: non parte da una massa di file, ma da una base interrogabile di fatti strutturali.

Ho anche aggiunto un middleware per limitare l’uso dei tool e riassumere i thread lunghi prima che diventino troppo costosi:

def _initialize_middleware(self):
    return [
        ToolCallLimitMiddleware(tool_name="query_graph", thread_limit=100, run_limit=30),
        ToolCallLimitMiddleware(tool_name="get_code", thread_limit=500, run_limit=300),
        SummarizationMiddleware(
            model=self.llm,
            trigger=[("tokens", 200000)],
            keep=("messages", 20),
        ),
    ]

Il Builder: recupero del contesto e generazione del codice

Il Builder fa soprattutto due cose:

  1. recupera documentazione rilevante;
  2. genera il modulo target con quel contesto.

In forma semplificata, il tool centrale è questo:

# agents/builder_agent.py (simplified)
@tool
def generate_code_snippet(input_json: str) -> str:
    input_data = json.loads(input_json)
    python_code = input_data.get("python_code", "")
    description = input_data.get("description", "")

    docs = self.vectorstore.similarity_search(search_query, k=3)
    doc_context = "\n\n".join([doc.page_content for doc in docs])

    prompt = f"""Convert the following Python code to {self.config.target_language}.
Python Code: {python_code}
Description: {description}
Relevant Documentation: {doc_context}
Please provide the converted code with explanatory comments."""

    response = self.llm.invoke(prompt)
    return response.content

Il prompt, qui, conta meno del contesto. Il Builder arriva a questo step con un problema già ristretto: sa cosa deve convertire, con quali dipendenze e con quale contesto documentale.

Il Verifier: compiler checks e semantic review

Il Verifier si occupa di due cose principali:

  1. compiler checks: compila il codice generato e cattura errori di sintassi e type-checking;
  2. semantic review: controlla che la logica del codice e l’API rimangano coerenti con il piano prodotto dall’Analyzer.
# agents/verifier_agent.py (simplified)
@tool
def check_syntax(code: str) -> str:
    with tempfile.NamedTemporaryFile(suffix=".rs", delete=False) as tmp:
        tmp.write(code)
        tmp_path = tmp.name

    result = subprocess.run(
        ["rustc", "--crate-type=lib", "--error-format=json", tmp_path],
        capture_output=True,
        text=True,
        timeout=30,
    )

    return "Syntax OK" if result.returncode == 0 else result.stderr

Vale la pena dirlo in modo esplicito: l’autoconfidenza del generatore non è una strategia di verifica. Una pipeline di migrazione ha bisogno di compiler checks, review semantica e, se possibile, test eseguibili.

Caso di studio: conversione di TinyDB

Perché TinyDB

Cercavo un progetto Python da migrare in Rust che fosse abbastanza grande da far emergere i limiti di un approccio single-shot, ma non così grande da rendere l’esperimento ingestibile.

TinyDB mi è sembrata una buona scelta perché è:

  • non banale: ha più moduli, una query DSL, astrazioni di storage e dipendenze cross-module;
  • gestibile: è abbastanza piccolo da stare dentro tempi e costi ragionevoli;
  • ben documentato: la documentazione pubblica esiste ed è facile da ingestare;
  • ben testato: la test suite originale offre un riferimento utile.

Insomma: abbastanza semplice da chiudere l’esperimento, abbastanza complesso da non ridursi a un toy example.

L’esperimento

Il run multi-agent è durato circa 18 minuti e ha consumato grosso modo 2,95 milioni di token.

RuoloModelloChiamateToken InputToken OutputTotale
OrchestratorClaude Opus 4.5402,362,13861,8202,423,958
Code AnalyzerClaude Haiku 4.59200,24016,646216,886
BuilderClaude Sonnet 4.539230,78381,763312,546
VerifierClaude Sonnet 4.511,5454291,974

Qui emerge subito una cosa: il costo maggiore non è nel Builder, ma nell’Orchestrator. La pipeline porta più struttura, ma introduce anche un overhead di coordinamento non banale.

L’execution trace ha catturato 248 entry e 112 tool call. Il pattern generale è stato questo:

  1. l’Orchestrator delega l’analisi al Code Analyzer;
  2. l’Analyzer interroga il graph per identificare moduli, classi, dipendenze e componenti ad alta complessità;
  3. l’Analyzer restituisce un piano di conversione ordinato secondo le dipendenze;
  4. l’Orchestrator lancia cinque task del Builder in parallelo per i moduli foglia: error, utils, storages, operations e queries;
  5. quando i builder finiscono, l’Orchestrator lancia i moduli dipendenti: table, database, middlewares, più lib.rs e Cargo.toml;
  6. il Verifier rivede l’output generato;
  7. l’Orchestrator scrive il codice finale e gli artifact di report.

Nei log si vede bene il dispatch parallelo dei leaf module:

[tool:start] task | builder_agent: "Generate error.rs"
[tool:start] task | builder_agent: "Generate utils.rs"
[tool:start] task | builder_agent: "Generate storages.rs"
[tool:start] task | builder_agent: "Generate operations.rs"
[tool:start] task | builder_agent: "Generate queries.rs"
[agent:start] BuilderAgent (x5)

L’output generato

La pipeline ha prodotto un progetto Rust completo con 9 source file, 4.081 linee di codice, 147 test e un paio di esempi:

tinydb-rs/
  Cargo.toml
  README.md
  src/
    lib.rs
    error.rs
    utils.rs
    storages.rs
    queries.rs
    operations.rs
    table.rs
    database.rs
    middlewares.rs

Un esempio del design generato è l’API generica TinyDB<S: Storage>:

use crate::error::Result;
use crate::storages::Storage;
use crate::table::{Document, Table, UpdateFields};

pub const DEFAULT_TABLE_NAME: &str = "_default";
pub const DEFAULT_CACHE_SIZE: usize = 10;

pub struct TinyDB<S: Storage> {
    storage: S,
    default_table_name: String,
    cache_size: usize,
}

impl<S: Storage> TinyDB<S> {
    pub fn new(storage: S) -> Self { /* ... */ }

    pub fn insert(&self, document: &Value) -> Result<u32> {
        self.default_table().insert(document)
    }

    pub fn search(&self, query: &dyn QueryLike) -> Result<Vec<Document>> {
        self.default_table().search(query)
    }
}

Multi-agent vs vibe coding

Per capire se tutta questa complessità aggiuntiva producesse davvero un vantaggio, ho confrontato l’output multi-agent con una baseline molto più semplice: un singolo run di coding assistant, usando lo stesso modello di generazione del Builder multi-agent.

Il prompt della baseline era il più diretto possibile:

Convert the TinyDB Python package to Rust.

Per ottenere un confronto fair, quel run single-shot ha usato lo stesso modello del Builder multi-agent: Claude Sonnet 4.5.

Il confronto tra i due output l’ho fatto in due modi:

  • con un assessment delegato a Opus 4.6 (reasoning max);
  • con un’ispezione manuale del codice, per validare i punti architetturali e i bug più importanti.

Non considero questo un benchmark scientifico o indipendente. È un esperimento pratico su una codebase specifica, utile per capire pattern e tradeoff, non per proclamare un vincitore universale.

Metriche della codebase

MetricaMulti-AgentVibe-CodedDelta
Linee di codice4,0811,9372.1x in più
Test totali147393.8x in più
Query operators14956% in più
Update operations11683% in più
Doc-tests4494.9x in più
Working examples20Solo multi-agent

Da questo punto di vista, il risultato multi-agent era chiaramente più completo.

Differenze architetturali

Le differenze non erano solo quantitative. C’erano anche due scelte di design piuttosto nette.

La prima riguarda il modello di astrazione dello storage.

La versione multi-agent usava generics:

TinyDB<S: Storage>

La versione vibe-coded usava invece dynamic dispatch con synchronization wrappers:

Arc<RwLock<Box<dyn Storage>>>

La soluzione generica è più idiomatica in Rust e, come ci si aspetta, si è comportata meglio in lettura grazie alla monomorphization. La versione a dynamic dispatch è più semplice da comporre e, in questi test, è risultata più veloce in scrittura.

La seconda grande differenza riguarda la concorrenza:

  • la versione multi-agent usava RefCell internamente e poi marcava alcuni tipi come Send e Sync con unsafe impl, cosa chiaramente sbagliata e che ha portato a un bug critico di unsafety;
  • la versione vibe-coded usava RwLock, quindi una primitiva molto più ragionevole sul piano della thread-safety, ma la sua implementazione andava in deadlock dentro insert_multiple.

Quindi nessuna delle due versioni era bug-free. Hanno semplicemente fallito in modi diversi.

Principali bug trovati

ProblemaMulti-AgentVibe-CodedSeverità
unsafe impl Send/Sync unsound su RefCellNoCritical
Deadlock in insert_multipleNoCritical
Coercizione silenziosa integer-to-floatNoHigh
Panic su regex non validaNoHigh

Benchmark di performance

Negli stress test in scrittura ha vinto la versione vibe-coded, con un vantaggio vicino a 2x:

BenchmarkMulti-AgentVibe-CodedVincitore
Bulk Insert (1000)796.76 us436.20 usVibe-coded
Update Query (2000)3.31 ms1.83 msVibe-coded

Negli stress test in lettura, invece, ha vinto la versione multi-agent:

BenchmarkMulti-AgentVibe-CodedVincitore
Read All (2000)1.01 ms1.52 msMulti-agent
Search Eq (2000)112.66 us120.75 usMulti-agent

Se guardassi solo lo scoreboard grezzo dei benchmark, potrei dire che la versione vibe-coded “vince” 6-4, soprattutto perché le prove di scrittura pesano parecchio.

Ma fermarsi lì sarebbe fuorviante. L’output multi-agent arriva con più test, copertura API più ampia, documentazione eseguibile e meno problemi di correttezza. Il suo difetto più serio è concentrato in una singola decisione sbagliata sulla concorrenza. La versione vibe-coded è più snella e più veloce in scrittura, ma richiederebbe un lavoro sostanziale per recuperare le feature mancanti e chiudere i bug di correttezza.

Una sorpresa sul tema thread-safety

C’è stato anche un passaggio che mi ha fatto cambiare idea mentre guardavo i risultati.

A prima vista la versione vibe-coded sembrava semplicemente migliore sul piano concettuale: RwLock è una primitiva Rust sensata, mentre RefCell più unsafe impl Send/Sync è un errore evidente.

Poi però sono tornato alla libreria Python originale e mi sono accorto di un dettaglio importante: TinyDB non aveva mai promesso thread-safety. Anzi, la documentazione cita esplicitamente “access from multiple processes or threads” tra i motivi per cui non usare TinyDB.1

Nel progetto originale non esiste una vera strategia di sincronizzazione. Il core read-modify-write flow non è protetto, e la concorrenza è sostanzialmente fuori scope.

Questo significa che:

  • la versione multi-agent, pur sbagliando l’unsafe, era in realtà più vicina al design intent originale, perché rimaneva concettualmente single-threaded;
  • la versione vibe-coded introduceva un modello di concorrenza più forte, che la libreria originale non aveva mai promesso.

Per me questa è stata una lezione utile: un porting non va valutato solo su ciò che il linguaggio target rende possibile, ma anche su ciò che il progetto sorgente intendeva davvero essere.

Cosa mi porto a casa da questo esperimento

Più che proclamare la vittoria del multi-agent, questo esperimento mi chiarisce sei cose.

1. I fatti strutturali vanno estratti con strumenti deterministici

Se l’AST può dirti quali moduli importano cosa, quali classi ereditano da quali basi e quali funzioni si chiamano tra loro, non ha molto senso pagare token costosi per farlo dedurre a un LLM.

2. La scelta del modello va fatta per ruolo, non per moda

L’Analyzer può essere piccolo ed economico perché legge dati strutturati. Il Builder, invece, è il punto in cui un modello più forte produce davvero valore. Usare lo stesso modello “top” ovunque è spesso solo uno spreco.

3. Rendere esplicito il dependency graph sblocca parallelismo reale

Quando le dipendenze sono esplicite, l’Orchestrator può costruire i moduli indipendenti in parallelo, partendo dai nodi foglia e salendo poi verso i moduli più centrali. È un vantaggio che in una conversione single-shot, per definizione, non esiste.

Probabilmente questo beneficio diventerebbe ancora più evidente su codebase molto più grandi di TinyDB.

4. La verifica non è opzionale

Compiler checks, semantic review e test non sono un lusso. Sono la parte minima per distinguere un port plausibile da un port affidabile.

5. La documentazione conta quasi quanto il codice

Quando c’è, Context7 è probabilmente la strada più pratica. Quando non c’è, un vector store locale rimane il fallback universale, soprattutto in scenari enterprise o su framework interni.

6. Ridurre lo spazio esplorativo migliora l’affidabilità, ma non garantisce l’ottimo

L’approccio multi-agent ha ristretto i margini di manovra del modello e lo ha spinto verso una soluzione più conservativa e più fedele al design originale. La versione vibe-coded, invece, ha esplorato più liberamente. Ha tagliato via molte feature e ha commesso errori seri, ma in alcuni punti ha anche preso decisioni più aggressive e localmente più efficaci per Rust.

Quindi no: più controllo non significa automaticamente design migliore in assoluto. Significa soprattutto output più governabile, più verificabile e meno arbitrario.

Limiti dell’esperimento e prossimi passi

Questo progetto è ancora esplorativo, quindi vale la pena dire chiaramente cosa non dimostra: non dimostra che il multi-agent sia sempre meglio del single-shot, che questa pipeline sia pronta per qualunque codebase Python o che il costo di orchestrazione, oggi, sia già ottimizzato.

Dimostra però che, almeno in questo caso, aggiungere struttura prima della generazione porta a un output più completo e più vicino al design originale.

I prossimi step più sensati, per me, sono questi:

  • codebase più grandi: ripetere l’esperimento su qualcosa di molto più grosso di TinyDB;
  • verify-revise loop più stretti: far girare il Verifier dopo ogni modulo, invece di usarlo una sola volta alla fine;
  • più linguaggi target: il framework supporta già più compiler e andrebbe testato oltre Rust;
  • costo di orchestrazione più basso: la maggior parte dei token è stata consumata dall’Orchestrator, quindi c’è margine per ridurre l’overhead di coordinamento.

Il source code completo, il benchmark report, l’execution trace e il crate Rust generato sono disponibili in macc.

Se state facendo esperimenti simili sulla conversione di codice, è questo il confronto che mi interessa: non tanto “quale prompt funziona meglio”, ma quale architettura riduce davvero l’ambiguità del problema.


  1. Documentazione di TinyDB, “Why Not Use TinyDB?”, che cita esplicitamente l’“access from multiple processes or threads” tra i non-obiettivi del progetto: https://tinydb.readthedocs.io/en/latest/intro.html ↩︎