From 94d4eb3e7d7c265e8fa4158baab28c525b8e1078 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 16:50:52 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(squad):=20Onda=201=20=E2=80=94=20ativa?= =?UTF-8?q?=20RunTool=20no=20Rust=20(squad=20como=20executor)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CoreBackend ganha run_tool (core_server.rs); o handler gRPC vira um passthrough fino em vez de Unimplemented — RunTool já existia no proto, zero mudança breaking. core_run_tool (squad.rs) recalcula o escopo real via Tool::scope (o ToolCall.scope da rede é só informativo, nunca decide permissão), avalia via PermissionEngine (perfil BUILD), roda a ferramenta em spawn_blocking e registra squad.tool_run no ledger. Os três CoreBackend de produção (GatewayCoreBackend, WebSquadCoreBackend, ScriptedSquadCoreBackend) ganham ToolRegistry/PermissionEngine e delegam a core_run_tool, cada um reusando sua própria ponte HITL já existente. Testes de fronteira (core_server_inprocess.rs, sem Python): um ToolCall via UDS puro cria um arquivo real no disco; uma negação do motor de permissões não executa nada. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01K6H8cTKxEw8uPHW8L38apx --- Cargo.lock | 3 + crates/forge-cli/src/session.rs | 48 +++-- crates/forge-cli/src/squad.rs | 161 ++++++++++++++- crates/forge-cli/src/squad_agent.rs | 67 ++++++- crates/forge-sidecar/Cargo.toml | 3 + crates/forge-sidecar/src/core_server.rs | 30 ++- crates/forge-sidecar/src/service.rs | 10 + .../tests/core_server_inprocess.rs | 185 ++++++++++++++++-- crates/forge-sidecar/tests/squad_e2e.rs | 22 ++- schemas/proto/core.proto | 6 + 10 files changed, 492 insertions(+), 43 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7f3e997..9d08b60 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -874,9 +874,12 @@ dependencies = [ name = "forge-sidecar" version = "0.1.0" dependencies = [ + "forge-core", "forge-proto", + "forge-tools", "hyper-util", "libc", + "serde_json", "tempfile", "thiserror 2.0.18", "tokio", diff --git a/crates/forge-cli/src/session.rs b/crates/forge-cli/src/session.rs index 33da1a2..a8b6770 100644 --- a/crates/forge-cli/src/session.rs +++ b/crates/forge-cli/src/session.rs @@ -100,17 +100,12 @@ impl Session { } } -/// Registra uma entrada avulsa no MESMO ledger (`.forge/forge.db`), fora do -/// ciclo de vida de uma `Session` de tarefa — usado por mutações de -/// configuração (matriz de permissão, Fase 7 Onda 2) que não têm -/// `session.start`/`session.end` próprios. Sempre marcada como `override`: -/// afrouxar/restringir permissão pelo navegador é a mutação mais sensível -/// deste plano e nunca deve passar em silêncio pelo ledger. -pub fn append_override_entry( +fn append_entry_impl( root: &Path, actor: &str, kind: &str, payload: Value, + r#override: Option, ) -> anyhow::Result<()> { let dir = root.join(".forge"); std::fs::create_dir_all(&dir)?; @@ -122,16 +117,47 @@ pub fn append_override_entry( kind: kind.into(), actor: actor.into(), payload, - r#override: Some(OverrideMark { - marked: true, - reason: None, - }), + r#override, fake_marker: None, ts: now_rfc3339(), })?; Ok(()) } +/// Registra uma entrada avulsa no MESMO ledger (`.forge/forge.db`), fora do +/// ciclo de vida de uma `Session` de tarefa — usado por mutações de +/// configuração (matriz de permissão, Fase 7 Onda 2) que não têm +/// `session.start`/`session.end` próprios. Sempre marcada como `override`: +/// afrouxar/restringir permissão pelo navegador é a mutação mais sensível +/// deste plano e nunca deve passar em silêncio pelo ledger. +pub fn append_override_entry( + root: &Path, + actor: &str, + kind: &str, + payload: Value, +) -> anyhow::Result<()> { + append_entry_impl( + root, + actor, + kind, + payload, + Some(OverrideMark { + marked: true, + reason: None, + }), + ) +} + +/// Registra uma entrada avulsa no MESMO ledger, fora do ciclo de vida de +/// uma `Session` de tarefa e SEM marcação de override — usado pela +/// execução de ferramenta via `RunTool` (squad como executor, "tool +/// execution architecture"): o `CoreBackend::run_tool` roda numa task +/// separada da `Session` que `squad.rs::try_squad` abre para a tarefa, sem +/// acesso a ela. +pub fn append_entry(root: &Path, actor: &str, kind: &str, payload: Value) -> anyhow::Result<()> { + append_entry_impl(root, actor, kind, payload, None) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/forge-cli/src/squad.rs b/crates/forge-cli/src/squad.rs index f0dbcee..44ff542 100644 --- a/crates/forge-cli/src/squad.rs +++ b/crates/forge-cli/src/squad.rs @@ -7,22 +7,165 @@ use crate::session::{now_rfc3339, Session}; use crate::{run_once, RunOpts}; use anyhow::Result; +use forge_core::{Decision, PermissionEngine}; use forge_llm::chat::{ChatMessage, GenerateRequest}; use forge_llm::Generator; -use forge_proto::core::PermissionRequest; +use forge_proto::core::{PermissionRequest, ToolCall, ToolResult}; use forge_proto::llm::{LlmRequest, Usage}; use forge_proto::squad::{squad_event, SquadTask}; use forge_sidecar::{serve_core, CoreBackend, SquadRun, SquadSupervisor}; +use forge_tools::ToolRegistry; use serde_json::json; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; +/// `ToolResult.exit_code`: convenção compartilhada pelos três `CoreBackend` +/// de produção (ver `core_run_tool`). `0` = sucesso; `1` = erro de +/// execução/args inválidos/ferramenta desconhecida (nunca rodou, ou rodou e +/// falhou — vale tentar de novo com outra entrada); `-1` = negado pelo +/// motor de permissões ou por um humano (nunca chegou a executar — não +/// adianta repetir a mesma ação). +pub(crate) const TOOL_EXIT_OK: i32 = 0; +pub(crate) const TOOL_EXIT_ERROR: i32 = 1; +pub(crate) const TOOL_EXIT_DENIED: i32 = -1; + +/// `CoreBackend::run_tool` de verdade, compartilhado pelos três backends de +/// produção (CLI, web, scripted). Recalcula o escopo a partir de +/// `args_json` via `Tool::scope` — o `ToolCall.scope` vindo da rede NUNCA é +/// usado na decisão de permissão (só o Rust decide escopo; um Python +/// bugado/comprometido não pode declarar um escopo mais permissivo que o +/// real). Avalia via `PermissionEngine`; no caso `Ask`, delega a decisão a +/// `ask` (o mesmo bridge de HITL que o backend já usa para +/// `request_permission` — stdin no CLI, `SquadHub::request_hitl` na web). +/// A execução síncrona (`tool.run`) roda dentro de `spawn_blocking`; a +/// checagem de permissão (incluindo o `Ask` assíncrono) fica fora — não +/// bloqueia uma worker-thread do reactor esperando um clique humano. +/// Registra cada chamada no ledger de `root` (best-effort — falha de +/// ledger nunca derruba a execução da ferramenta). +pub(crate) async fn core_run_tool( + tools: &Arc, + permissions: &PermissionEngine, + call: &ToolCall, + root: &Path, + ask: F, +) -> ToolResult +where + F: FnOnce(PermissionRequest) -> Fut, + Fut: std::future::Future, +{ + let args: serde_json::Value = match serde_json::from_str(&call.args_json) { + Ok(v) => v, + Err(e) => { + return ToolResult { + content: format!("args_json inválido: {e}"), + truncated: false, + exit_code: TOOL_EXIT_ERROR, + } + } + }; + if tools.get(&call.tool).is_none() { + return ToolResult { + content: format!("ferramenta desconhecida: {}", call.tool), + truncated: false, + exit_code: TOOL_EXIT_ERROR, + }; + } + let scope = tools.get(&call.tool).expect("validado acima").scope(&args); + + let allowed = match permissions.evaluate(&call.tool, &scope) { + Decision::Allow => true, + Decision::Deny => false, + Decision::Ask => { + ask(PermissionRequest { + tool: call.tool.clone(), + scope: scope.clone(), + reason: format!("squad pede '{}' em {scope:?}", call.tool), + confidence: 0.0, + }) + .await + } + }; + if !allowed { + let result = ToolResult { + content: format!("permissão negada para {} em {scope:?}", call.tool), + truncated: false, + exit_code: TOOL_EXIT_DENIED, + }; + log_tool_run(root, call, &scope, &result); + return result; + } + + let tools_for_blocking = Arc::clone(tools); + let tool_name = call.tool.clone(); + let run_result = tokio::task::spawn_blocking(move || { + let tool = tools_for_blocking + .get(&tool_name) + .expect("validado antes do spawn_blocking"); + tool.run(&args) + }) + .await; + + let result = match run_result { + Ok(Ok(out)) => { + let mut content = out.content; + if out.truncated { + match &out.overflow_path { + Some(path) => content.push_str(&format!( + "\n[output truncado; completo em {path} — use read para consultar]" + )), + None => content.push_str("\n[output truncado]"), + } + } + ToolResult { + content, + truncated: out.truncated, + exit_code: TOOL_EXIT_OK, + } + } + Ok(Err(e)) => ToolResult { + content: e.to_string(), + truncated: false, + exit_code: TOOL_EXIT_ERROR, + }, + Err(e) => ToolResult { + content: format!("falha interna ao rodar ferramenta: {e}"), + truncated: false, + exit_code: TOOL_EXIT_ERROR, + }, + }; + log_tool_run(root, call, &scope, &result); + result +} + +/// Best-effort — nunca deixa uma falha de ledger derrubar a resposta do +/// `RunTool` (mesma postura de `Session::note`). +fn log_tool_run(root: &Path, call: &ToolCall, scope: &str, result: &ToolResult) { + if let Err(e) = crate::session::append_entry( + root, + "forge-cli:squad-tool", + "squad.tool_run", + json!({ + "tool": call.tool, + "scope": scope, + "exit_code": result.exit_code, + "truncated": result.truncated, + }), + ) { + eprintln!(" [ledger] falha ao registrar squad.tool_run: {e}"); + } +} + /// `CoreBackend` real: `Generate` passa pelo `Gateway` (streaming agregado), -/// `RequestPermission` resolve HITL no terminal (ou auto-aprova com `--yes`). +/// `RequestPermission` resolve HITL no terminal (ou auto-aprova com `--yes`), +/// `RunTool` executa de verdade sob `ToolRegistry`/`PermissionEngine` +/// ("tool execution architecture" — squad como executor). struct GatewayCoreBackend { generator: Arc, auto_yes: bool, + root: PathBuf, + tools: Arc, + tool_permissions: PermissionEngine, } #[derive(serde::Deserialize)] @@ -108,6 +251,17 @@ impl CoreBackend for GatewayCoreBackend .await .unwrap_or(false) } + + async fn run_tool(&self, call: &ToolCall) -> ToolResult { + core_run_tool( + &self.tools, + &self.tool_permissions, + call, + &self.root, + |req| async move { self.request_permission(&req).await }, + ) + .await + } } /// Localiza o workspace Python do sidecar: `FORGE_PYTHON_DIR`, senão um @@ -153,6 +307,9 @@ pub async fn run_squad( let backend = GatewayCoreBackend { generator: generator.clone(), auto_yes: opts.yes, + root: root.to_path_buf(), + tools: Arc::new(ToolRegistry::default_set(root)), + tool_permissions: (forge_core::BUILD.permissions)(), }; let core_task = tokio::spawn(serve_core(backend, core_sock.clone())); for _ in 0..100 { diff --git a/crates/forge-cli/src/squad_agent.rs b/crates/forge-cli/src/squad_agent.rs index c3e77a4..b7ec238 100644 --- a/crates/forge-cli/src/squad_agent.rs +++ b/crates/forge-cli/src/squad_agent.rs @@ -17,18 +17,20 @@ //! Resolver isso de verdade (core_socket por slot + CoreService por slot) //! é escopo maior, deixado para uma onda futura. -use crate::squad::{core_generate, locate_python_dir}; +use crate::squad::{core_generate, core_run_tool, locate_python_dir}; use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::response::sse::{Event, KeepAlive, Sse}; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use axum::{Json, Router}; +use forge_core::PermissionEngine; use forge_llm::gateway::Generator; -use forge_proto::core::PermissionRequest; +use forge_proto::core::{PermissionRequest, ToolCall, ToolResult}; use forge_proto::llm::{LlmRequest, Usage}; use forge_proto::squad::{squad_event, SquadEvent, SquadTask}; use forge_sidecar::{serve_core, CoreBackend, SidecarError, SquadPool}; +use forge_tools::ToolRegistry; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::convert::Infallible; @@ -176,11 +178,16 @@ impl SquadHub { /// `CoreBackend` real do agente web: `Generate` passa pelo `Gateway`/rate /// limit/cache (mesmo `core_generate` do `forge squad` CLI); -/// `RequestPermission` resolve o gate via HTTP em vez de stdin. +/// `RequestPermission` resolve o gate via HTTP em vez de stdin; `RunTool` +/// executa de verdade sob `ToolRegistry`/`PermissionEngine` (mesmo +/// `core_run_tool` do CLI). struct WebSquadCoreBackend { generator: Arc, hub: SquadHub, task_id: String, + root: PathBuf, + tools: Arc, + tool_permissions: PermissionEngine, } #[tonic::async_trait] @@ -192,6 +199,17 @@ impl CoreBackend for WebSquadCoreBackend bool { self.hub.request_hitl(&self.task_id).await } + + async fn run_tool(&self, call: &ToolCall) -> ToolResult { + core_run_tool( + &self.tools, + &self.tool_permissions, + call, + &self.root, + |_req| self.hub.request_hitl(&self.task_id), + ) + .await + } } /// `CoreBackend` roteirizado (e2e sem API key, `FORGE_SCRIPTED=1`, mesmo @@ -202,6 +220,9 @@ impl CoreBackend for WebSquadCoreBackend, + tool_permissions: PermissionEngine, } #[tonic::async_trait] @@ -236,6 +257,17 @@ impl CoreBackend for ScriptedSquadCoreBackend { async fn request_permission(&self, _req: &PermissionRequest) -> bool { self.hub.request_hitl(&self.task_id).await } + + async fn run_tool(&self, call: &ToolCall) -> ToolResult { + core_run_tool( + &self.tools, + &self.tool_permissions, + call, + &self.root, + |_req| self.hub.request_hitl(&self.task_id), + ) + .await + } } fn now_rfc3339() -> String { @@ -254,7 +286,7 @@ async fn run_squad_task( root: PathBuf, task_id: String, description: String, - backend_for: impl FnOnce(SquadHub, String) -> B, + backend_for: impl FnOnce(SquadHub, String, PathBuf, Arc, PermissionEngine) -> B, ) where B: CoreBackend, { @@ -289,7 +321,7 @@ async fn run_squad_task_inner( root: PathBuf, task_id: String, description: String, - backend_for: impl FnOnce(SquadHub, String) -> B, + backend_for: impl FnOnce(SquadHub, String, PathBuf, Arc, PermissionEngine) -> B, ) -> Result<(), String> where B: CoreBackend, @@ -300,7 +332,17 @@ where // pool — nunca duas tarefas vivas ao mesmo tempo disputando o bind). let core_sock = forge_dir.join("squad-pool-core.sock"); - let backend = backend_for(hub.clone(), task_id.clone()); + // Construído uma vez aqui (não em cada closure de `backend_for`) — + // evita montar `ToolRegistry::default_set` duas vezes por variante. + let tools = Arc::new(ToolRegistry::default_set(&root)); + let tool_permissions = (forge_core::BUILD.permissions)(); + let backend = backend_for( + hub.clone(), + task_id.clone(), + root.clone(), + tools, + tool_permissions, + ); let core_task = tokio::spawn(serve_core(backend, core_sock.clone())); for _ in 0..100 { if core_sock.exists() { @@ -427,7 +469,13 @@ async fn run_squad_handler( root, task_id_for_task, body.task, - |hub, task_id| ScriptedSquadCoreBackend { hub, task_id }, + |hub, task_id, root, tools, tool_permissions| ScriptedSquadCoreBackend { + hub, + task_id, + root, + tools, + tool_permissions, + }, )); } else { let opts = crate::RunOpts { @@ -454,10 +502,13 @@ async fn run_squad_handler( root, task_id_for_task, body.task, - move |hub, task_id| WebSquadCoreBackend { + move |hub, task_id, root, tools, tool_permissions| WebSquadCoreBackend { generator, hub, task_id, + root, + tools, + tool_permissions, }, )); } diff --git a/crates/forge-sidecar/Cargo.toml b/crates/forge-sidecar/Cargo.toml index fac2253..8f39a32 100644 --- a/crates/forge-sidecar/Cargo.toml +++ b/crates/forge-sidecar/Cargo.toml @@ -19,3 +19,6 @@ libc = "0.2" [dev-dependencies] tempfile.workspace = true +forge-tools.workspace = true +forge-core.workspace = true +serde_json.workspace = true diff --git a/crates/forge-sidecar/src/core_server.rs b/crates/forge-sidecar/src/core_server.rs index 8940cd4..1a2820a 100644 --- a/crates/forge-sidecar/src/core_server.rs +++ b/crates/forge-sidecar/src/core_server.rs @@ -1,13 +1,18 @@ //! Servidor `CoreService` (`schemas/proto/core.proto`) — o lado Rust do //! laço bidirecional (Onda 4d). O sidecar Python do squad chama de volta -//! `Generate` (LLM) e `RequestPermission` (HITL) enquanto executa. +//! `Generate` (LLM), `RequestPermission` (HITL) e `RunTool` (execução real +//! de ferramenta, ativada na "tool execution architecture" — squad como +//! executor) enquanto executa. //! -//! `Generate`/`RequestPermission` são atendidos por um [`CoreBackend`] -//! injetável: em produção, o `Gateway` real (forge-cli) + um resolver de -//! permissão; em teste, um backend roteirizado. Os demais RPCs do contrato -//! (`RunTool`/`AppendLedger`/`Recall`/`Remember`) devolvem `Unimplemented` -//! honestamente — o orquestrador atual não os chama (os agentes fazem uma -//! única chamada de LLM e a memória é local ao Python). +//! `Generate`/`RequestPermission`/`RunTool` são atendidos por um +//! [`CoreBackend`] injetável: em produção, o `Gateway` real (forge-cli) + +//! um `ToolRegistry`/`PermissionEngine` + um resolver de permissão; em +//! teste, um backend roteirizado. `AppendLedger` devolve `Unimplemented` +//! honestamente — não usado pelo orquestrador atual. `Recall`/`Remember` +//! continuam dormentes de propósito, mas não por falta de tempo: são a +//! direção errada para memória (`CoreService` é servido pelo Rust, chamado +//! pelo Python; memória mora no Python) — superados pelo `MemoryService` +//! novo, servido pelo Python (ADR 0022). use forge_proto::core::core_service_server::{CoreService, CoreServiceServer}; use forge_proto::core::{ @@ -31,6 +36,11 @@ pub trait CoreBackend: Send + Sync + 'static { /// Decide uma permissão (true = ALLOW). Fail-closed é responsabilidade /// de quem implementa. async fn request_permission(&self, req: &PermissionRequest) -> bool; + /// Executa uma ferramenta. Nunca falha a RPC — negação do motor de + /// permissões ou erro de execução viram um `ToolResult` com o + /// `exit_code` apropriado, não um `Status` de transporte (mesmo + /// espírito de `generate`: erro de domínio vira payload). + async fn run_tool(&self, call: &ToolCall) -> ToolResult; } pub struct CoreServer { @@ -96,9 +106,9 @@ impl CoreService for CoreServer { })) } - async fn run_tool(&self, _: Request) -> Result, Status> { - Err(Status::unimplemented( - "RunTool não usado pelo orquestrador atual (agentes fazem uma chamada de LLM)", + async fn run_tool(&self, request: Request) -> Result, Status> { + Ok(Response::new( + self.backend.run_tool(&request.into_inner()).await, )) } diff --git a/crates/forge-sidecar/src/service.rs b/crates/forge-sidecar/src/service.rs index dd28a9d..6e79f35 100644 --- a/crates/forge-sidecar/src/service.rs +++ b/crates/forge-sidecar/src/service.rs @@ -610,5 +610,15 @@ mod tests { async fn request_permission(&self, _req: &forge_proto::core::PermissionRequest) -> bool { true } + async fn run_tool( + &self, + _call: &forge_proto::core::ToolCall, + ) -> forge_proto::core::ToolResult { + forge_proto::core::ToolResult { + content: "NoopCore não executa ferramentas".into(), + truncated: false, + exit_code: 1, + } + } } } diff --git a/crates/forge-sidecar/tests/core_server_inprocess.rs b/crates/forge-sidecar/tests/core_server_inprocess.rs index ab3bb8f..bbaf50c 100644 --- a/crates/forge-sidecar/tests/core_server_inprocess.rs +++ b/crates/forge-sidecar/tests/core_server_inprocess.rs @@ -2,10 +2,13 @@ //! sem Python. Se isto passar e o e2e com Python falhar, o problema é //! interop; se falhar aqui, é a implementação do servidor. +use forge_core::{Decision, PermissionEngine, Rule}; use forge_proto::core::core_service_client::CoreServiceClient; -use forge_proto::core::PermissionRequest; +use forge_proto::core::{PermissionRequest, ToolCall, ToolResult}; use forge_proto::llm::{llm_chunk, LlmRequest, Usage}; use forge_sidecar::{serve_core, CoreBackend}; +use forge_tools::ToolRegistry; +use std::sync::Arc; use std::time::Duration; use tonic::transport::{Endpoint, Uri}; use tonic::Request; @@ -28,20 +31,89 @@ impl CoreBackend for Backend { async fn request_permission(&self, _req: &PermissionRequest) -> bool { true } + async fn run_tool(&self, _call: &ToolCall) -> ToolResult { + ToolResult { + content: "Backend não executa ferramentas".into(), + truncated: false, + exit_code: 1, + } + } } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn core_service_responde_generate_em_processo() { - let sock = std::env::temp_dir().join(format!("forge-core-inproc-{}.sock", std::process::id())); - let core_task = tokio::spawn(serve_core(Backend, sock.clone())); - for _ in 0..100 { - if sock.exists() { - break; +/// Backend com `ToolRegistry`/`PermissionEngine` reais — prova `RunTool` +/// isoladamente do squad Python (cliente Rust ↔ servidor Rust sobre UDS). +struct BackendWithTools { + tools: Arc, + permissions: PermissionEngine, +} + +#[tonic::async_trait] +impl CoreBackend for BackendWithTools { + async fn generate(&self, _req: &LlmRequest) -> Result<(String, Usage), String> { + Err("BackendWithTools não gera texto".into()) + } + async fn request_permission(&self, _req: &PermissionRequest) -> bool { + true + } + async fn run_tool(&self, call: &ToolCall) -> ToolResult { + // Mesma lógica de `forge-cli::squad::core_run_tool`, sem depender de + // `forge-cli` (evitaria um ciclo de dependência de teste) — isolado + // o bastante para provar o contrato do lado do servidor `CoreService`. + let args: serde_json::Value = match serde_json::from_str(&call.args_json) { + Ok(v) => v, + Err(e) => { + return ToolResult { + content: format!("args_json inválido: {e}"), + truncated: false, + exit_code: 1, + } + } + }; + let Some(tool) = self.tools.get(&call.tool) else { + return ToolResult { + content: format!("ferramenta desconhecida: {}", call.tool), + truncated: false, + exit_code: 1, + }; + }; + let scope = tool.scope(&args); + let allowed = match self.permissions.evaluate(&call.tool, &scope) { + Decision::Allow => true, + Decision::Deny => false, + Decision::Ask => { + self.request_permission(&PermissionRequest { + tool: call.tool.clone(), + scope: scope.clone(), + reason: String::new(), + confidence: 0.0, + }) + .await + } + }; + if !allowed { + return ToolResult { + content: format!("permissão negada para {} em {scope:?}", call.tool), + truncated: false, + exit_code: -1, + }; + } + match tool.run(&args) { + Ok(out) => ToolResult { + content: out.content, + truncated: out.truncated, + exit_code: 0, + }, + Err(e) => ToolResult { + content: e.to_string(), + truncated: false, + exit_code: 1, + }, } - tokio::time::sleep(Duration::from_millis(20)).await; } +} - let sock_c = sock.clone(); +async fn connect_client(sock: &std::path::Path) -> CoreServiceClient { + let sock_c = sock.to_path_buf(); let channel = Endpoint::try_from("http://core.invalid") .unwrap() .connect_with_connector(tower::service_fn(move |_: Uri| { @@ -53,7 +125,24 @@ async fn core_service_responde_generate_em_processo() { })) .await .expect("conectar ao core"); - let mut client = CoreServiceClient::new(channel); + CoreServiceClient::new(channel) +} + +async fn wait_for_socket(sock: &std::path::Path) { + for _ in 0..100 { + if sock.exists() { + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn core_service_responde_generate_em_processo() { + let sock = std::env::temp_dir().join(format!("forge-core-inproc-{}.sock", std::process::id())); + let core_task = tokio::spawn(serve_core(Backend, sock.clone())); + wait_for_socket(&sock).await; + let mut client = connect_client(&sock).await; let mut stream = client .generate(Request::new(LlmRequest { @@ -85,3 +174,77 @@ async fn core_service_responde_generate_em_processo() { assert_eq!(text, "resposta para architect"); assert!(usage_seen, "deveria ter recebido um chunk de usage"); } + +/// Onda 1 — boundary test do executor: um `ToolCall` de verdade sobre UDS +/// puro (sem Python nenhum) produz um arquivo real no disco. Prova +/// `RunTool` isolado da implementação do servidor, não do squad inteiro. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn run_tool_executa_de_verdade_e_arquivo_aparece_no_disco() { + let dir = tempfile::tempdir().unwrap(); + let backend = BackendWithTools { + tools: Arc::new(ToolRegistry::default_set(dir.path())), + permissions: PermissionEngine::default(), + }; + let sock = + std::env::temp_dir().join(format!("forge-core-runtool-ok-{}.sock", std::process::id())); + let core_task = tokio::spawn(serve_core(backend, sock.clone())); + wait_for_socket(&sock).await; + let mut client = connect_client(&sock).await; + + let result = client + .run_tool(Request::new(ToolCall { + tool: "bash".into(), + args_json: serde_json::json!({"command": "echo conteudo > out.txt"}).to_string(), + scope: String::new(), + })) + .await + .expect("RunTool deveria responder") + .into_inner(); + core_task.abort(); + + assert_eq!(result.exit_code, 0, "conteúdo: {}", result.content); + let written = std::fs::read_to_string(dir.path().join("out.txt")) + .expect("out.txt deveria existir no workspace"); + assert_eq!(written.trim(), "conteudo"); +} + +/// Onda 1 — negação do motor de permissões não executa nada: nem o +/// `exit_code` de sucesso, nem o arquivo aparecem. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn run_tool_negado_pela_permissao_nao_executa() { + let dir = tempfile::tempdir().unwrap(); + let backend = BackendWithTools { + tools: Arc::new(ToolRegistry::default_set(dir.path())), + permissions: PermissionEngine { + rules: vec![Rule { + tool: "bash".into(), + scope_prefix: None, + decision: Decision::Deny, + }], + }, + }; + let sock = std::env::temp_dir().join(format!( + "forge-core-runtool-denied-{}.sock", + std::process::id() + )); + let core_task = tokio::spawn(serve_core(backend, sock.clone())); + wait_for_socket(&sock).await; + let mut client = connect_client(&sock).await; + + let result = client + .run_tool(Request::new(ToolCall { + tool: "bash".into(), + args_json: serde_json::json!({"command": "echo conteudo > negado.txt"}).to_string(), + scope: String::new(), + })) + .await + .expect("RunTool deveria responder mesmo negando") + .into_inner(); + core_task.abort(); + + assert_eq!(result.exit_code, -1); + assert!( + !dir.path().join("negado.txt").exists(), + "negado não deveria ter criado o arquivo" + ); +} diff --git a/crates/forge-sidecar/tests/squad_e2e.rs b/crates/forge-sidecar/tests/squad_e2e.rs index 838eb43..f3d7943 100644 --- a/crates/forge-sidecar/tests/squad_e2e.rs +++ b/crates/forge-sidecar/tests/squad_e2e.rs @@ -9,7 +9,7 @@ //! Rust para cada agente e streama os eventos de volta. Pulado (sem //! falhar) se `uv`/workspace Python ausentes — como `python_sidecar.rs`. -use forge_proto::core::PermissionRequest; +use forge_proto::core::{PermissionRequest, ToolCall, ToolResult}; use forge_proto::llm::{LlmRequest, Usage}; use forge_proto::squad::{handoff, squad_event, SquadEvent, SquadTask}; use forge_sidecar::{drain_stream, serve_core, CoreBackend, SquadRun, SquadSupervisor}; @@ -57,6 +57,18 @@ impl CoreBackend for ScriptedCore { async fn request_permission(&self, _req: &PermissionRequest) -> bool { true } + + // Estes dois testes não roteirizam nenhuma ação de ferramenta (o plano + // roteirizado do "planner" nunca gera um passo "implement" com + // tool_call) — RunTool nunca é chamado aqui. `ScriptedCoreWithTools` + // (Onda 3, no fechamento) é quem exercita RunTool de verdade. + async fn run_tool(&self, _call: &ToolCall) -> ToolResult { + ToolResult { + content: "ScriptedCore não executa ferramentas".into(), + truncated: false, + exit_code: 1, + } + } } // Runtime multi-thread: o CoreService (respondendo os callbacks do Python) @@ -200,6 +212,14 @@ impl CoreBackend for SlowCore { async fn request_permission(&self, _req: &PermissionRequest) -> bool { true } + + async fn run_tool(&self, _call: &ToolCall) -> ToolResult { + ToolResult { + content: "SlowCore não executa ferramentas".into(), + truncated: false, + exit_code: 1, + } + } } /// Critério de aceite da Fase 4: `kill -9` no sidecar dispara o fallback. diff --git a/schemas/proto/core.proto b/schemas/proto/core.proto index 90ef0fb..7419324 100644 --- a/schemas/proto/core.proto +++ b/schemas/proto/core.proto @@ -24,12 +24,18 @@ service CoreService { message ToolCall { string tool = 1; string args_json = 2; + // Informativo — o lado Rust SEMPRE recalcula o escopo real a partir de + // args_json (Tool::scope) para a decisão de permissão; este campo nunca + // é a fonte de verdade para Allow/Ask/Deny. string scope = 3; } message ToolResult { string content = 1; bool truncated = 2; + // 0 = sucesso; 1 = erro de execução/args inválidos/ferramenta desconhecida + // (nunca chegou a rodar, ou rodou e falhou); -1 = negado pelo motor de + // permissões ou por um humano (nunca chegou a executar). int32 exit_code = 3; } From 1515296730b2d39c6c0e3592c45407138ec16d00 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 17:03:04 +0000 Subject: [PATCH 2/3] =?UTF-8?q?feat(squad):=20Onda=202=20=E2=80=94=20loop?= =?UTF-8?q?=20ReAct=20real=20no=20developer,=20chama=20RunTool=20de=20verd?= =?UTF-8?q?ade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tool_client.py (novo) + GrpcToolClient (grpc_clients.py): ToolClient Protocol sobre CoreService.RunTool — zero codegen novo, o stub Python já existia gerado e nunca usado. developer.py: _implement_with_tools é o loop ReAct real — o modelo alterna entre tool_call (executado via tool_client, sob o motor de permissões do Rust) e final_answer, até um dos dois ou até estourar _MAX_REACT_STEPS/_REACT_TIMEOUT_SECONDS (honesto: "incomplete", nunca fabrica sucesso). Sinal de ativação: bool(task.get("action")) — separa trabalho real do plano de proposta/avaliação sem tocar o caminho de chamada única existente. Achado real ao revisar o plano antes de implementar: _can_parallelize manda um passo "implement" sem dependencies (o caso comum) para _extract_parallel_tasks, que chamava o developer SEM "action" — o sinal de ativação nunca disparava nesse caminho, reproduzindo o bug original mesmo com RunTool/ReAct prontos. Fix: _extract_parallel_tasks propaga action/prior_results (mesma forma do step_task sequencial). Regressão coberta por teste que falha sem o fix (verificado manualmente revertendo e rodando antes de reaplicar). core_generate (squad.rs) ganha o papel "assistant" (Role::Assistant) — sem isto, o histórico multi-turno do loop ReAct colapsava tudo em Role::User, e a API da Anthropic exige alternância estrita. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01K6H8cTKxEw8uPHW8L38apx --- crates/forge-cli/src/squad.rs | 91 ++++++++++- .../src/forge_squad/agents/developer.py | 152 ++++++++++++++++-- .../src/forge_squad/grpc_clients.py | 22 +++ .../src/forge_squad/orchestrator.py | 36 ++++- .../forge-squad/src/forge_squad/server.py | 9 +- .../src/forge_squad/tool_client.py | 55 +++++++ .../forge-squad/tests/test_developer.py | 65 +++++++- .../forge-squad/tests/test_orchestrator.py | 69 ++++++++ 8 files changed, 474 insertions(+), 25 deletions(-) create mode 100644 python/packages/forge-squad/src/forge_squad/tool_client.py diff --git a/crates/forge-cli/src/squad.rs b/crates/forge-cli/src/squad.rs index 44ff542..601a2af 100644 --- a/crates/forge-cli/src/squad.rs +++ b/crates/forge-cli/src/squad.rs @@ -8,7 +8,7 @@ use crate::session::{now_rfc3339, Session}; use crate::{run_once, RunOpts}; use anyhow::Result; use forge_core::{Decision, PermissionEngine}; -use forge_llm::chat::{ChatMessage, GenerateRequest}; +use forge_llm::chat::{ChatMessage, ContentBlock, GenerateRequest, Role}; use forge_llm::Generator; use forge_proto::core::{PermissionRequest, ToolCall, ToolResult}; use forge_proto::llm::{LlmRequest, Usage}; @@ -188,13 +188,24 @@ pub(crate) async fn core_generate( let mut system = String::new(); let mut chat = Vec::new(); for m in msgs { - if m.role == "system" { - if !system.is_empty() { - system.push('\n'); + match m.role.as_str() { + "system" => { + if !system.is_empty() { + system.push('\n'); + } + system.push_str(&m.content); } - system.push_str(&m.content); - } else { - chat.push(ChatMessage::user_text(&m.content)); + // Loop ReAct do squad (Onda 2) manda histórico multi-turno de + // verdade — sem isto, um "assistant" cairia em `Role::User` e a + // API da Anthropic (que exige alternância estrita user/ + // assistant) recusaria/malformaria a conversa. Todo caller + // anterior mandava só 1 system + 1 user, então este ramo nunca + // foi exercitado antes do loop ReAct existir. + "assistant" => chat.push(ChatMessage { + role: Role::Assistant, + content: vec![ContentBlock::Text { text: m.content }], + }), + _ => chat.push(ChatMessage::user_text(&m.content)), } } let gen_req = GenerateRequest { @@ -505,3 +516,69 @@ fn safe_mode(task: &str) { ou o sidecar Python para reativar squad/agente-único." ); } + +#[cfg(test)] +mod tests { + use super::*; + use forge_llm::chat::{AssistantTurn, StopReason, Usage as ChatUsage}; + use forge_llm::gateway::GatewayError; + use std::sync::Mutex; + + /// Gerador de teste que só registra as `messages` recebidas — usado + /// para provar o mapeamento de papel de `core_generate` (Onda 2), sem + /// precisar de um provider real. + struct RecordingGenerator { + received: Mutex>>, + } + + impl Generator for RecordingGenerator { + async fn generate( + &self, + req: GenerateRequest, + _on_delta: &mut (dyn FnMut(&str) + Send), + ) -> Result { + self.received.lock().unwrap().push(req.messages); + Ok(AssistantTurn { + content: vec![ContentBlock::Text { text: "ok".into() }], + stop_reason: StopReason::EndTurn, + usage: ChatUsage { + input_tokens: 1, + output_tokens: 1, + }, + provider: "recording".into(), + }) + } + } + + #[tokio::test] + async fn core_generate_mapeia_papel_assistant_para_role_assistant() { + let generator = RecordingGenerator { + received: Mutex::new(Vec::new()), + }; + let messages_json = serde_json::to_string(&serde_json::json!([ + {"role": "system", "content": "prompt de sistema"}, + {"role": "user", "content": "tarefa"}, + {"role": "assistant", "content": "{\"action\":\"tool_call\"}"}, + {"role": "user", "content": "observação"}, + ])) + .unwrap(); + let req = LlmRequest { + model: "m".into(), + messages_json, + temperature: None, + max_tokens: None, + requester: "developer".into(), + }; + + core_generate(&generator, &req).await.expect("generate ok"); + + let received = generator.received.lock().unwrap(); + let messages = &received[0]; + // system não entra em `messages` (vira `GenerateRequest.system`) — + // só as duas mensagens de chat + a de assistant sobram, na ordem. + assert_eq!(messages.len(), 3); + assert_eq!(messages[0].role, Role::User); + assert_eq!(messages[1].role, Role::Assistant); + assert_eq!(messages[2].role, Role::User); + } +} diff --git a/python/packages/forge-squad/src/forge_squad/agents/developer.py b/python/packages/forge-squad/src/forge_squad/agents/developer.py index 7511943..eab1b84 100644 --- a/python/packages/forge-squad/src/forge_squad/agents/developer.py +++ b/python/packages/forge-squad/src/forge_squad/agents/developer.py @@ -4,13 +4,16 @@ Na origem, o "loop ReAct" (`think`/`decide_action`/`execute_action`) era uma máquina de estados 100% roteirizada — cada "pensamento" e "observação" era uma string canned escolhida por keyword matching, sem -nenhuma chamada real. Como `CoreService.RunTool` ainda não existe (ativa -só na Onda 4), um loop ReAct de múltiplas iterações executando -ferramentas de verdade não é possível ainda — fingir várias iterações -sem execução real seria trocar uma fabricação por outra. Esta versão -troca a máquina de estados por **uma chamada real** ao gateway que -implementa a tarefa e reporta status/confiança — honesto sobre o escopo -atual, sem fabricar múltiplos passos que não fariam nada de verdade. +nenhuma chamada real. Por um bom tempo, enquanto `CoreService.RunTool` +era só um stub `Unimplemented`, este agente trocou a máquina de estados +por uma única chamada ao gateway — honesto sobre não poder executar +nada de verdade. Com `RunTool` ativado (Onda 1 — "tool execution +architecture"), `_implement_with_tools` é o loop ReAct real: o modelo +decide entre `tool_call` (executado de verdade via `ToolClient`, sob o +motor de permissões do lado Rust) e `final_answer`, iterando até um dos +dois ou até estourar o teto de passos/tempo. O caminho de chamada única +(`implement_task` com `use_tools=False`) continua existindo para +proposta/avaliação, onde nenhuma ferramenta deveria rodar. `review_system` é injetado como dependência opcional (ADR 0005, decisão 4) — sem ele, `generate_code` devolve o código gerado sem revisão; o @@ -19,6 +22,7 @@ from __future__ import annotations +import asyncio import json import logging import re @@ -26,6 +30,7 @@ from forge_squad.agents.base import BaseAgent from forge_squad.gateway import LlmRequest +from forge_squad.tool_client import ToolCallRequest, ToolClient logger = logging.getLogger(__name__) @@ -38,6 +43,18 @@ } Todos os campos devem refletir a tarefa específica recebida — nunca um placeholder genérico.""" +_REACT_SYSTEM_PROMPT = """Você é um desenvolvedor full-stack sênior com acesso a ferramentas reais (read, grep, edit, bash), executadas sob um motor de permissões do lado do núcleo — edit/bash podem pedir aprovação humana; se uma ação for negada, mude de estratégia, não repita a mesma ação. 'edit' só funciona em um arquivo que já existe; para CRIAR um arquivo novo, use 'bash' (ex.: heredoc/redirecionamento). + +A cada passo, responda SOMENTE com um objeto JSON (sem markdown, sem texto fora do JSON), em uma das duas formas: +{"action": "tool_call", "tool": "read|grep|edit|bash", "args": {...}, "reasoning": "string — por que esta ação"} +ou, só depois de ter executado o necessário via tool_call (nunca alegue ter criado ou salvo algo sem ter pedido a execução real): +{"action": "final_answer", "final_output": "string — resumo do que foi feito para ESTA tarefa", "status": "completed ou incomplete", "confidence": 0.0, "notes": "string"} + +Depois de criar ou editar um arquivo, rode um comando de verificação (ex.: sha256sum ou cat no arquivo) antes do final_answer — essa saída é a evidência que o auditor vai ver; sem ela, uma alegação de sucesso não tem lastro.""" + +_MAX_REACT_STEPS = 12 +_REACT_TIMEOUT_SECONDS = 600 + _JSON_BLOCK = re.compile(r"\{.*\}", re.DOTALL) @@ -57,13 +74,23 @@ def __init__(self, model: str = "claude-sonnet-5", review_system: Optional[Revie self.review_system = review_system self.history: list[dict[str, Any]] = [] self.tools = ["write_code", "generate_tests", "refactor", "debug", "analyze_requirements"] + self.tool_client: Optional[ToolClient] = None + + def attach_tool_client(self, tool_client: ToolClient) -> None: + self.tool_client = tool_client async def execute(self, task: dict[str, Any]) -> dict[str, Any]: if not self.validate_input(task): raise ValueError("Invalid development task payload") description = task.get("description", "") - result = await self.implement_task(description) + # Sinal de ativação do loop ReAct: todo passo real do plano carrega + # "action" (`_select_agent_for_step`/`step_task`, + # `orchestrator.py`); chamadas de proposta/avaliação nunca setam + # essa chave — então isto separa "trabalho real do plano" de "só + # avaliar/propor" sem hardcodar um vocabulário de ações. + use_tools = bool(task.get("action")) and self.tool_client is not None + result = await self.implement_task(description, use_tools=use_tools) decision = { "task": task, "result": result, @@ -119,15 +146,22 @@ async def auto_fix_issues(self, code: str, reviews: dict[str, Any]) -> str: return code - async def implement_task(self, task: str) -> dict[str, Any]: - """Chamada real ao gateway que implementa a tarefa (ver docstring - do módulo sobre o escopo atual do loop ReAct).""" + async def implement_task(self, task: str, use_tools: bool = False) -> dict[str, Any]: + """Chamada real ao gateway que implementa a tarefa. `use_tools=True` + (e `self.tool_client` anexado) usa o loop ReAct real + (`_implement_with_tools`); caso contrário, uma única chamada — + usado por proposta/avaliação, onde nenhuma ferramenta deve rodar.""" if self.gateway is None: raise RuntimeError( "DeveloperAgent sem gateway anexado — chame attach_gateway() antes de execute()" ) + if use_tools and self.tool_client is not None: + result = await self._implement_with_tools(task) + self.history.append({"task": task, "result": result}) + return result + request = LlmRequest( model=self.model, messages=[ @@ -141,6 +175,102 @@ async def implement_task(self, task: str) -> dict[str, Any]: self.history.append({"task": task, "result": result}) return result + async def _implement_with_tools(self, task: str) -> dict[str, Any]: + """Loop ReAct real: o modelo alterna entre `tool_call` (executado + de verdade via `self.tool_client`, sob o motor de permissões do + lado Rust) e `final_answer`, até um dos dois ou até estourar + `_MAX_REACT_STEPS`/`_REACT_TIMEOUT_SECONDS` — nesse caso, devolve + honestamente `status: "incomplete"`, nunca fabrica sucesso.""" + + async def _run_loop() -> dict[str, Any]: + messages: list[dict[str, str]] = [ + {"role": "system", "content": _REACT_SYSTEM_PROMPT}, + {"role": "user", "content": task.strip() or "Tarefa não especificada"}, + ] + tool_calls: list[dict[str, Any]] = [] + for _ in range(_MAX_REACT_STEPS): + request = LlmRequest(model=self.model, messages=messages, requester=self.agent_type) + raw = await self.gateway.generate(request) + action = self._parse_react_action(raw.text) + + if action["action"] == "final_answer": + return { + "final_output": action.get("final_output", ""), + "status": action.get("status", "incomplete"), + "confidence": float(action.get("confidence", 0.0)), + "notes": action.get("notes", ""), + "tool_calls": tool_calls, + } + + if action["action"] == "tool_call": + messages.append({"role": "assistant", "content": raw.text}) + result = await self.tool_client.run_tool( + ToolCallRequest(tool=action["tool"], args_json=json.dumps(action.get("args", {}))) + ) + tool_calls.append( + { + "tool": action["tool"], + "args": action.get("args", {}), + "exit_code": result.exit_code, + "content": result.content, + } + ) + observation = { + "tool": action["tool"], + "content": result.content, + "truncated": result.truncated, + "exit_code": result.exit_code, + } + messages.append({"role": "user", "content": json.dumps(observation, ensure_ascii=False)}) + continue + + # parse_error ou discriminador desconhecido — pede pro + # modelo tentar de novo em vez de quebrar o loop. + messages.append({"role": "assistant", "content": raw.text}) + messages.append( + { + "role": "user", + "content": ( + 'erro: responda SOMENTE com o JSON {"action": "tool_call", ...} ' + 'ou {"action": "final_answer", ...}' + ), + } + ) + + logger.warning("Loop ReAct do developer esgotou %d passos sem final_answer", _MAX_REACT_STEPS) + return { + "final_output": "", + "status": "incomplete", + "confidence": 0.0, + "notes": f"loop de ferramentas esgotou {_MAX_REACT_STEPS} passos sem final_answer", + "tool_calls": tool_calls, + } + + try: + return await asyncio.wait_for(_run_loop(), timeout=_REACT_TIMEOUT_SECONDS) + except asyncio.TimeoutError: + return { + "final_output": "", + "status": "incomplete", + "confidence": 0.0, + "notes": f"loop de ferramentas excedeu {_REACT_TIMEOUT_SECONDS}s", + "tool_calls": [], + } + + def _parse_react_action(self, raw_text: str) -> dict[str, Any]: + match = _JSON_BLOCK.search(raw_text) + if not match: + logger.warning("Resposta do modelo (ReAct) não contém um bloco JSON: %r", raw_text[:200]) + return {"action": "parse_error"} + try: + candidate = json.loads(match.group(0)) + except json.JSONDecodeError: + logger.warning("Resposta do modelo (ReAct) não é JSON válido: %r", raw_text[:200]) + return {"action": "parse_error"} + if not isinstance(candidate, dict) or candidate.get("action") not in {"tool_call", "final_answer"}: + return {"action": "parse_error"} + return candidate + def _parse_result(self, raw_text: str) -> dict[str, Any]: parsed: dict[str, Any] = {} match = _JSON_BLOCK.search(raw_text) diff --git a/python/packages/forge-squad/src/forge_squad/grpc_clients.py b/python/packages/forge-squad/src/forge_squad/grpc_clients.py index fafa622..a66da2a 100644 --- a/python/packages/forge-squad/src/forge_squad/grpc_clients.py +++ b/python/packages/forge-squad/src/forge_squad/grpc_clients.py @@ -21,6 +21,7 @@ from forge_squad.gateway import LlmRequest, LlmResponse from forge_squad.permission import PermissionDecision, PermissionRequest +from forge_squad.tool_client import ToolCallRequest, ToolCallResult class GrpcGatewayClient: @@ -84,3 +85,24 @@ async def request_permission(self, request: PermissionRequest) -> PermissionDeci approved = decision.decision == core_pb2.PermissionDecision.ALLOW note = decision.operator_note if decision.HasField("operator_note") else None return PermissionDecision(approved=approved, operator_note=note) + + +class GrpcToolClient: + """`ToolClient` sobre `CoreService.RunTool` — execução real de + ferramenta do lado Rust ("tool execution architecture", Onda 1/2).""" + + def __init__(self, channel) -> None: + self._stub = core_pb2_grpc.CoreServiceStub(channel) + + async def run_tool(self, request: ToolCallRequest) -> ToolCallResult: + proto_req = core_pb2.ToolCall( + tool=request.tool, + args_json=request.args_json, + scope=request.scope, + ) + result = await self._stub.RunTool(proto_req) + return ToolCallResult( + content=result.content, + truncated=result.truncated, + exit_code=result.exit_code, + ) diff --git a/python/packages/forge-squad/src/forge_squad/orchestrator.py b/python/packages/forge-squad/src/forge_squad/orchestrator.py index 81a1c7f..65bdfd3 100644 --- a/python/packages/forge-squad/src/forge_squad/orchestrator.py +++ b/python/packages/forge-squad/src/forge_squad/orchestrator.py @@ -41,6 +41,7 @@ from forge_squad.planning import AdaptivePlanner from forge_squad.routing import LearningRouter from forge_squad.sandbox import SecureToolSandbox +from forge_squad.tool_client import ToolClient logger = logging.getLogger(__name__) @@ -62,6 +63,7 @@ def __init__( permission_client: Optional[PermissionClient] = None, model: str = "claude-sonnet-5", memory: Optional[AgentMemorySystem] = None, + tool_client: Optional[ToolClient] = None, ) -> None: self.planner = AdaptivePlanner(model=model) self.planner.attach_gateway(gateway) @@ -91,6 +93,11 @@ def __init__( for agent in self.agents.values(): agent.attach_memory(self.memory) agent.attach_gateway(gateway) + # Só o developer executa ferramentas hoje ("tool execution + # architecture", Onda 1/2) — não é um `attach_*` genérico em loop + # como memory/gateway porque nenhum outro agente tem esse método. + if tool_client is not None: + self.agents["developer"].attach_tool_client(tool_client) async def _emit(self, event: dict[str, Any]) -> None: if self._event_sink is not None: @@ -237,7 +244,7 @@ async def _execute_plan_steps(self, plan: dict[str, Any], task: dict[str, Any]) {"kind": "handoff", "phase": "start", "from_agent": "orchestrator", "to_agent": agent_name} ) if self._can_parallelize(step, plan): - parallel_tasks = self._extract_parallel_tasks(step) + parallel_tasks = self._extract_parallel_tasks(step, results) step_results = await self.parallel.execute_parallel_with_limits(parallel_tasks) for result in step_results: await self.evaluator.evaluate_agent_performance(agent_name, result) @@ -287,14 +294,35 @@ def _can_parallelize(self, step: dict[str, Any], plan: dict[str, Any]) -> bool: return False return True - def _extract_parallel_tasks(self, step: dict[str, Any]): + def _extract_parallel_tasks(self, step: dict[str, Any], results: list[dict[str, Any]]): description = step.get("description", "") + # Mesma forma do `step_task` sequencial (:248-257) — sem isto, um + # passo "implement" sem `dependencies` (o caso comum: cai aqui, não + # no ramo sequencial) chegava ao developer sem "action", e o sinal + # de ativação do loop ReAct (`DeveloperAgent.execute`) nunca + # disparava. Achado real: era exatamente o caminho que reproduzia o + # bug original (squad não materializa arquivo) mesmo depois do + # RunTool/loop ReAct existirem. + action = step.get("action", "") + prior_results = list(results) async def developer_task() -> dict[str, Any]: - return await self.agents["developer"].execute({"description": f"Parallel dev: {description}"}) + return await self.agents["developer"].execute( + { + "description": f"Parallel dev: {description}", + "action": action, + "prior_results": prior_results, + } + ) async def designer_task() -> dict[str, Any]: - return await self.agents["designer"].execute({"description": f"Parallel design: {description}"}) + return await self.agents["designer"].execute( + { + "description": f"Parallel design: {description}", + "action": action, + "prior_results": prior_results, + } + ) return [developer_task, designer_task] diff --git a/python/packages/forge-squad/src/forge_squad/server.py b/python/packages/forge-squad/src/forge_squad/server.py index 15f2c11..3e7ad34 100644 --- a/python/packages/forge-squad/src/forge_squad/server.py +++ b/python/packages/forge-squad/src/forge_squad/server.py @@ -32,7 +32,7 @@ from forge_proto import squad_pb2, squad_pb2_grpc -from forge_squad.grpc_clients import GrpcGatewayClient, GrpcPermissionClient +from forge_squad.grpc_clients import GrpcGatewayClient, GrpcPermissionClient, GrpcToolClient from forge_squad.memory import AgentMemorySystem from forge_squad.orchestrator import UnifiedOrchestrator @@ -151,9 +151,14 @@ async def sink(event: dict[str, Any]) -> None: ) gateway = GrpcGatewayClient(channel) permission = GrpcPermissionClient(channel) + tool_client = GrpcToolClient(channel) memory = AgentMemorySystem(storage_dir=self.memory_dir) if self.memory_dir else AgentMemorySystem() orchestrator = UnifiedOrchestrator( - gateway, permission_client=permission, model=self.model, memory=memory + gateway, + permission_client=permission, + model=self.model, + memory=memory, + tool_client=tool_client, ) async def run() -> None: diff --git a/python/packages/forge-squad/src/forge_squad/tool_client.py b/python/packages/forge-squad/src/forge_squad/tool_client.py new file mode 100644 index 0000000..0772b93 --- /dev/null +++ b/python/packages/forge-squad/src/forge_squad/tool_client.py @@ -0,0 +1,55 @@ +"""Contrato do client de ferramentas consumido pelo `DeveloperAgent` — +desacoplado do transporte gRPC real (`CoreService.RunTool`, +`schemas/proto/core.proto`) pelo mesmo motivo do `GatewayClient`/ +`PermissionClient` (ADR 0005). "Tool execution architecture" (Onda 2): o +loop ReAct do developer chama `run_tool`, que executa de verdade do lado +Rust sob `ToolRegistry`/`PermissionEngine` — o Python nunca toca disco. +""" + +from __future__ import annotations + +from typing import Protocol + +from pydantic import BaseModel + + +class ToolCallRequest(BaseModel): + """Espelha `forge.core.v1.ToolCall` (`schemas/proto/core.proto`).""" + + tool: str + args_json: str + scope: str = "" + + +class ToolCallResult(BaseModel): + """Espelha `forge.core.v1.ToolResult`. `exit_code`: 0 sucesso, 1 erro de + execução/args inválidos/ferramenta desconhecida, -1 negado pelo motor de + permissões ou por um humano (nunca chegou a executar).""" + + content: str + truncated: bool = False + exit_code: int = 0 + + +class ToolClient(Protocol): + """Contrato consumido pelo `DeveloperAgent`. A implementação real fala + gRPC com `CoreService.RunTool`; testes usam `ScriptedToolClient`. + """ + + async def run_tool(self, request: ToolCallRequest) -> ToolCallResult: ... + + +class ScriptedToolClient: + """Client de ferramentas falso e determinístico para testes — mesmo + princípio do `ScriptedGatewayClient`/`ScriptedPermissionClient`. + """ + + def __init__(self, results: list[ToolCallResult]) -> None: + self._results = list(results) + self.requests: list[ToolCallRequest] = [] + + async def run_tool(self, request: ToolCallRequest) -> ToolCallResult: + self.requests.append(request) + if not self._results: + raise AssertionError("ScriptedToolClient esgotou os resultados roteirizados") + return self._results.pop(0) diff --git a/python/packages/forge-squad/tests/test_developer.py b/python/packages/forge-squad/tests/test_developer.py index 18dc626..092354b 100644 --- a/python/packages/forge-squad/tests/test_developer.py +++ b/python/packages/forge-squad/tests/test_developer.py @@ -1,8 +1,9 @@ import asyncio import json -from forge_squad.agents.developer import DeveloperAgent +from forge_squad.agents.developer import _MAX_REACT_STEPS, DeveloperAgent from forge_squad.gateway import LlmResponse, ScriptedGatewayClient +from forge_squad.tool_client import ScriptedToolClient, ToolCallResult def test_execute_deriva_saida_real_do_gateway(): @@ -83,3 +84,65 @@ async def review_code(self, code, metadata): code = asyncio.run(agent.generate_code({"description": "hello world"})) assert code == "print('oi revisado')" + + +def test_execute_com_action_usa_tool_client_e_faz_tool_call_antes_do_final_answer(): + tool_call_turn = json.dumps( + {"action": "tool_call", "tool": "bash", "args": {"command": "echo oi > out.txt"}} + ) + final_turn = json.dumps( + { + "action": "final_answer", + "final_output": "arquivo criado", + "status": "completed", + "confidence": 0.9, + "notes": "verificado com cat", + } + ) + agent = DeveloperAgent() + agent.attach_gateway( + ScriptedGatewayClient([LlmResponse(text=tool_call_turn), LlmResponse(text=final_turn)]) + ) + tool_client = ScriptedToolClient([ToolCallResult(content="oi\n", exit_code=0)]) + agent.attach_tool_client(tool_client) + + result = asyncio.run(agent.execute({"description": "crie out.txt", "action": "implement"})) + + assert len(tool_client.requests) == 1 + assert tool_client.requests[0].tool == "bash" + assert json.loads(tool_client.requests[0].args_json) == {"command": "echo oi > out.txt"} + assert result["status"] == "completed" + assert result["final_output"] == "arquivo criado" + assert len(result["tool_calls"]) == 1 + assert result["tool_calls"][0]["exit_code"] == 0 + + +def test_execute_sem_action_nao_usa_tools_mesmo_com_tool_client_anexado(): + payload = {"final_output": "código", "status": "completed", "confidence": 0.8} + agent = DeveloperAgent() + agent.attach_gateway(ScriptedGatewayClient([LlmResponse(text=json.dumps(payload))])) + tool_client = ScriptedToolClient([ToolCallResult(content="não deveria ser chamado")]) + agent.attach_tool_client(tool_client) + + # Estilo proposta/avaliação (`_get_squad_proposals`) — sem "action". + result = asyncio.run(agent.execute({"description": "avalie a tarefa X"})) + + assert tool_client.requests == [] + assert result["final_output"] == "código" + assert "tool_calls" not in result # caminho antigo de chamada única, formato intacto + + +def test_react_loop_esgota_passos_sem_final_answer_devolve_incomplete_honesto(): + tool_call_turn = json.dumps({"action": "tool_call", "tool": "bash", "args": {"command": "ls"}}) + agent = DeveloperAgent() + agent.attach_gateway(ScriptedGatewayClient([LlmResponse(text=tool_call_turn)] * _MAX_REACT_STEPS)) + tool_client = ScriptedToolClient( + [ToolCallResult(content="arquivo.txt", exit_code=0) for _ in range(_MAX_REACT_STEPS)] + ) + agent.attach_tool_client(tool_client) + + result = asyncio.run(agent.execute({"description": "tarefa sem fim", "action": "implement"})) + + assert result["status"] == "incomplete" + assert result["final_output"] == "" + assert len(result["tool_calls"]) == _MAX_REACT_STEPS diff --git a/python/packages/forge-squad/tests/test_orchestrator.py b/python/packages/forge-squad/tests/test_orchestrator.py index d692823..2c53363 100644 --- a/python/packages/forge-squad/tests/test_orchestrator.py +++ b/python/packages/forge-squad/tests/test_orchestrator.py @@ -3,10 +3,12 @@ import pytest +from forge_squad.agents.developer import _MAX_REACT_STEPS from forge_squad.gateway import LlmRequest, LlmResponse from forge_squad.memory import AgentMemorySystem from forge_squad.orchestrator import UnifiedOrchestrator from forge_squad.permission import PermissionDecision, ScriptedPermissionClient +from forge_squad.tool_client import ScriptedToolClient, ToolCallResult class RoutingGatewayClient: @@ -245,3 +247,70 @@ def test_propostas_sao_envolvidas_em_proposal_e_consenso_computa(tmp_path): result = asyncio.run(orch.execute_complex_task({"description": "tarefa"})) assert result["consensus"]["decision_maker"] in {"architect", "developer", "auditor"} assert 0.0 <= result["consensus"]["consensus_strength"] <= 1.0 + + +def test_passo_implement_paralelizavel_ainda_ativa_tool_client_do_developer(tmp_path): + # Achado real (revisão do plano): `_can_parallelize` devolve True para + # um passo "implement" sem `dependencies` — o caso comum de um plano de + # 1 passo — e `_extract_parallel_tasks` chamava o developer SEM + # "action", então o sinal de ativação do loop ReAct + # (`DeveloperAgent.execute`) nunca disparava nesse caminho: era + # exatamente o caminho que reproduzia o bug original (squad não + # materializa arquivo) mesmo depois de RunTool/loop ReAct existirem. + # Sem o fix em `_extract_parallel_tasks` (propagar action/prior_results, + # mesma forma do `step_task` sequencial), este teste falha porque o + # tool_client nunca é chamado. + tool_call_turn = json.dumps({"action": "tool_call", "tool": "bash", "args": {"command": "echo oi"}}) + gateway = RoutingGatewayClient( + { + "planner": LlmResponse( + text=json.dumps( + { + "steps": [ + { + "step": 1, + "action": "implement", + "description": "criar arquivo", + "estimated_time": 5, + "dependencies": [], + "can_fail": True, + } + ], + "estimated_duration": 5, + "confidence": 0.8, + } + ) + ), + "architect": LlmResponse( + text=json.dumps( + {"problem_analysis": "x", "recommendation": "y", "architecture": "z", "components": [], "confidence": 0.9} + ) + ), + "developer": LlmResponse(text=tool_call_turn), + "auditor": LlmResponse( + text=json.dumps( + {"passed": True, "approved": True, "confidence": 0.9, "notes": "ok", "issues": [], "agent_scores": {}, "additional_checks": []} + ) + ), + "designer": LlmResponse(text=json.dumps({"pattern": "x", "components": [], "confidence": 0.8})), + } + ) + # `developer` está roteirizado pra sempre devolver o mesmo tool_call (o + # `RoutingGatewayClient` não é uma fila) — o loop ReAct esgota + # `_MAX_REACT_STEPS` sem nunca ver um final_answer; irrelevante pro que + # este teste prova (só que o tool_client FOI chamado), então provisiona + # resultados suficientes pra não esgotar o `ScriptedToolClient` antes. + tool_client = ScriptedToolClient( + [ToolCallResult(content="oi", exit_code=0) for _ in range(_MAX_REACT_STEPS)] + ) + orch = UnifiedOrchestrator( + gateway, + permission_client=ScriptedPermissionClient([PermissionDecision(approved=True)]), + memory=AgentMemorySystem(storage_dir=tmp_path), + tool_client=tool_client, + ) + + asyncio.run(orch.execute_complex_task({"description": "criar arquivo real"})) + + assert len(tool_client.requests) >= 1, "regressão do fix de _extract_parallel_tasks (action/prior_results)" + assert tool_client.requests[0].tool == "bash" From 4023bd2beadafa3586115f27b0611764e8f6479a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 17:17:37 +0000 Subject: [PATCH 3/3] =?UTF-8?q?feat(squad):=20Onda=203=20=E2=80=94=20evid?= =?UTF-8?q?=C3=AAncia=20real=20ao=20auditor,=20gate=20duro,=20fecho=20da?= =?UTF-8?q?=20ADR=200023?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit auditor.py: _claims_completion_without_write_evidence é um gate duro (mesma filosofia de forge_review/gates.py::evaluate) — reprova "completed" sem tool_calls de escrita bem-sucedida ANTES do gateway ser chamado. Só se aplica a resultados que passaram pelo loop ReAct (carregam a chave tool_calls, mesmo vazia); um resultado do caminho antigo de chamada única nunca teve infraestrutura de ferramenta disponível, então não é gateado — achado real ao rodar a suíte (sem essa distinção, o gate quebrava um teste legítimo da Onda 0 que usa o caminho antigo de propósito). orchestrator.py: o veredito final vira observável via um novo StepResult (step_id: "final_validation") — sem isto, "o auditor julga sobre o arquivo real" não seria verificável fora do dict de retorno que server.py descarta. Teste de fechamento (squad_e2e.rs, processo Python real, sem key): forge squad "crie scientific-calculator.html..." produz o arquivo de verdade no workspace, registra squad.tool_run no ledger, e o auditor aprova sobre evidência real — o backend roteirizado falha alto e claro (assert dentro do generate()) se o payload que chega ao Rust não carregar tool_calls, provando que o auditor não julga no vácuo. ADR 0023 documenta a decisão completa (Ondas 1-3) e os dois achados reais de implementação (caminho paralelo do orquestrador, papel "assistant" em core_generate). pendencias.md fecha o gap remanescente registrado na Onda 0. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01K6H8cTKxEw8uPHW8L38apx --- Cargo.lock | 2 + crates/forge-sidecar/Cargo.toml | 2 + crates/forge-sidecar/tests/squad_e2e.rs | 333 ++++++++++++++++++ ...023-runtool-ativado-squad-como-executor.md | 197 +++++++++++ pendencias.md | 21 ++ .../src/forge_squad/agents/auditor.py | 59 ++++ .../src/forge_squad/orchestrator.py | 23 ++ .../forge-squad/tests/test_auditor.py | 74 ++++ 8 files changed, 711 insertions(+) create mode 100644 docs/adr/0023-runtool-ativado-squad-como-executor.md diff --git a/Cargo.lock b/Cargo.lock index 9d08b60..aeb8cc5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -876,6 +876,8 @@ version = "0.1.0" dependencies = [ "forge-core", "forge-proto", + "forge-schemas", + "forge-store", "forge-tools", "hyper-util", "libc", diff --git a/crates/forge-sidecar/Cargo.toml b/crates/forge-sidecar/Cargo.toml index 8f39a32..a88c133 100644 --- a/crates/forge-sidecar/Cargo.toml +++ b/crates/forge-sidecar/Cargo.toml @@ -21,4 +21,6 @@ libc = "0.2" tempfile.workspace = true forge-tools.workspace = true forge-core.workspace = true +forge-store.workspace = true +forge-schemas.workspace = true serde_json.workspace = true diff --git a/crates/forge-sidecar/tests/squad_e2e.rs b/crates/forge-sidecar/tests/squad_e2e.rs index f3d7943..81d6d8f 100644 --- a/crates/forge-sidecar/tests/squad_e2e.rs +++ b/crates/forge-sidecar/tests/squad_e2e.rs @@ -14,6 +14,8 @@ use forge_proto::llm::{LlmRequest, Usage}; use forge_proto::squad::{handoff, squad_event, SquadEvent, SquadTask}; use forge_sidecar::{drain_stream, serve_core, CoreBackend, SquadRun, SquadSupervisor}; use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; use std::time::Duration; fn python_workspace_dir() -> PathBuf { @@ -293,3 +295,334 @@ async fn kill_do_sidecar_dispara_fallback() { } } } + +/// Backend do Core roteirizado, com `ToolRegistry`/`PermissionEngine` +/// REAIS (Onda 1) — prova o fechamento da "tool execution architecture": +/// `forge squad "crie X.html..."` produz `X.html` de verdade no +/// workspace, registrado no ledger, com o auditor julgando sobre +/// evidência real (não texto que ele "acha" que foi produzido). +/// +/// `generate` sequencia respostas por chamada (não por `requester` fixo, +/// como `ScriptedCore`) para "developer"/"auditor": o developer precisa de +/// 3 respostas em ordem (proposta → tool_call → final_answer) e o auditor +/// de 2 (proposta → validate_results, esta última só aprovada se o +/// payload que chegou até aqui carregar evidência real de tool_call — +/// falha o teste alto e claro se não carregar, em vez de aprovar calado). +struct ScriptedCoreWithTools { + tools: Arc, + permissions: forge_core::PermissionEngine, + root: PathBuf, + filename: String, + developer_call: AtomicUsize, + auditor_call: AtomicUsize, +} + +impl ScriptedCoreWithTools { + /// Mesma lógica de `forge-cli::squad::core_run_tool` — duplicada aqui + /// porque `forge-sidecar` (onde este teste mora) não pode depender de + /// `forge-cli` (a direção de dependência é a oposta). Isolado o + /// bastante para provar o contrato do lado do servidor `CoreService`, + /// como `core_server_inprocess.rs::BackendWithTools` já faz. + async fn run_tool_real(&self, call: &ToolCall) -> ToolResult { + let args: serde_json::Value = match serde_json::from_str(&call.args_json) { + Ok(v) => v, + Err(e) => { + return ToolResult { + content: format!("args_json inválido: {e}"), + truncated: false, + exit_code: 1, + } + } + }; + let Some(tool) = self.tools.get(&call.tool) else { + return ToolResult { + content: format!("ferramenta desconhecida: {}", call.tool), + truncated: false, + exit_code: 1, + }; + }; + let scope = tool.scope(&args); + let allowed = match self.permissions.evaluate(&call.tool, &scope) { + forge_core::Decision::Allow => true, + forge_core::Decision::Deny => false, + forge_core::Decision::Ask => true, // request_permission sempre aprova neste teste + }; + let result = if !allowed { + ToolResult { + content: format!("permissão negada para {} em {scope:?}", call.tool), + truncated: false, + exit_code: -1, + } + } else { + match tool.run(&args) { + Ok(out) => ToolResult { + content: out.content, + truncated: out.truncated, + exit_code: 0, + }, + Err(e) => ToolResult { + content: e.to_string(), + truncated: false, + exit_code: 1, + }, + } + }; + self.log_tool_run(call, &scope, &result); + result + } + + /// Mesma forma de `session.rs::append_entry` (kind/actor/payload, sem + /// override) — duplicado pelo mesmo motivo de `run_tool_real`. + fn log_tool_run(&self, call: &ToolCall, scope: &str, result: &ToolResult) { + let dir = self.root.join(".forge"); + let _ = std::fs::create_dir_all(&dir); + let Ok(mut store) = forge_store::LedgerStore::open(dir.join("forge.db").to_str().unwrap()) + else { + return; + }; + let _ = store.append(forge_schemas::ledger::LedgerEntry { + seq: 0, + prev_hash: String::new(), + entry_hash: String::new(), + kind: "squad.tool_run".into(), + actor: "forge-cli:squad-tool".into(), + payload: serde_json::json!({ + "tool": call.tool, + "scope": scope, + "exit_code": result.exit_code, + "truncated": result.truncated, + }), + r#override: None, + fake_marker: None, + ts: "2026-01-01T00:00:00Z".into(), + }); + } +} + +#[tonic::async_trait] +impl CoreBackend for ScriptedCoreWithTools { + async fn generate(&self, req: &LlmRequest) -> Result<(String, Usage), String> { + let text = match req.requester.as_str() { + "planner" => format!( + r#"{{"steps":[{{"step":1,"action":"implement","description":"criar {}","estimated_time":10,"dependencies":["seed"],"can_fail":true}}],"estimated_duration":10,"confidence":0.8}}"#, + self.filename + ), + "architect" => { + r#"{"problem_analysis":"x","recommendation":"micro","architecture":"microservices","components":["api"],"confidence":0.9}"#.to_string() + } + "developer" => { + let call = self.developer_call.fetch_add(1, Ordering::SeqCst); + match call { + // Proposta inicial (_get_squad_proposals — caminho antigo, + // sem "action", nunca usa ferramenta). + 0 => r#"{"final_output":"vou criar o arquivo","status":"completed","confidence":0.2}"#.to_string(), + // 1ª iteração do loop ReAct: cria o arquivo de verdade via + // bash (só ele cria; edit exige que o arquivo já exista) + // e roda sha256sum — a evidência que o auditor vai ver. + 1 => serde_json::json!({ + "action": "tool_call", + "tool": "bash", + "args": { + "command": format!( + "printf 'Calculadora: soma(a,b)' > {} && sha256sum {}", + self.filename, self.filename + ) + }, + "reasoning": "criar o arquivo pedido e gerar evidência de verificação" + }) + .to_string(), + // 2ª iteração: já tem a observação da tool_call anterior + // (sha256sum) no histórico — encerra com final_answer. + _ => serde_json::json!({ + "action": "final_answer", + "final_output": format!("{} criado e verificado com sha256sum", self.filename), + "status": "completed", + "confidence": 0.9, + "notes": "arquivo criado via bash; sha256sum confirma o conteúdo" + }) + .to_string(), + } + } + "auditor" => { + let call = self.auditor_call.fetch_add(1, Ordering::SeqCst); + if call == 0 { + // Proposta inicial — sem prior_results, sem evidência + // esperada. + r#"{"passed":true,"approved":true,"confidence":0.2,"notes":"ok","issues":[],"agent_scores":{},"additional_checks":[]}"#.to_string() + } else { + // validate_results final — só aprova se o payload que + // chegou até aqui (via gRPC, do lado Python) carregar + // evidência real de tool_call. Falha ALTO E CLARO (não + // aprova calado) se a evidência não estiver lá — é + // exatamente isto que prova que o auditor não está + // julgando no vácuo. + assert!( + req.messages_json.contains(&self.filename), + "payload do auditor não menciona o arquivo — mensagens: {}", + req.messages_json + ); + assert!( + req.messages_json.contains("tool_calls"), + "payload do auditor não carrega tool_calls — mensagens: {}", + req.messages_json + ); + r#"{"approved":true,"confidence":0.9,"issues":[],"agent_scores":{}}"#.to_string() + } + } + other => return Err(format!("requester inesperado no fechamento: {other}")), + }; + Ok(( + text, + Usage { + input_tokens: 1, + output_tokens: 2, + cache_hit: false, + provider: "scripted-with-tools".into(), + }, + )) + } + + async fn request_permission(&self, _req: &PermissionRequest) -> bool { + true + } + + async fn run_tool(&self, call: &ToolCall) -> ToolResult { + self.run_tool_real(call).await + } +} + +/// O teste de fechamento da "tool execution architecture": prova, ponta a +/// ponta e com o processo Python REAL, a definição de pronto do parecer +/// original — `forge squad "crie X.html..."` produzindo `X.html` real no +/// workspace, registrado no ledger, com o auditor julgando sobre o +/// arquivo que existe de fato (não alegando às cegas). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn squad_cria_arquivo_real_via_run_tool_ledger_e_auditor_veem_evidencia() { + let dir = python_workspace_dir(); + if !dir.join("pyproject.toml").exists() { + eprintln!("workspace Python ausente em {dir:?} — pulando e2e de fechamento"); + return; + } + + let workspace_root = tempfile::tempdir().expect("tempdir do workspace"); + let filename = "scientific-calculator.html"; + + let backend = ScriptedCoreWithTools { + tools: Arc::new(forge_tools::ToolRegistry::default_set( + workspace_root.path(), + )), + permissions: forge_core::PermissionEngine::default(), + root: workspace_root.path().to_path_buf(), + filename: filename.to_string(), + developer_call: AtomicUsize::new(0), + auditor_call: AtomicUsize::new(0), + }; + + let pid = std::process::id(); + let core_sock = std::env::temp_dir().join(format!("forge-squad-close-core-{pid}.sock")); + let squad_sock = std::env::temp_dir().join(format!("forge-squad-close-{pid}.sock")); + + let core_task = tokio::spawn(serve_core(backend, core_sock.clone())); + for _ in 0..100 { + if core_sock.exists() { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + + let mut supervisor = + match SquadSupervisor::spawn(&dir, squad_sock, &core_sock, "claude-sonnet-5") { + Ok(s) => s, + Err(e) => { + eprintln!("não foi possível spawnar o squad ({e}) — pulando e2e de fechamento"); + core_task.abort(); + return; + } + }; + let mut client = supervisor + .wait_ready(Duration::from_secs(30)) + .await + .expect("squad Python real deveria ficar pronto"); + + let evidence_json = serde_json::json!({ + "run_id": "e2e-fechamento", + "git_sha": "e2e", + "steps": [], + "verdict": "pass", + "produced_at": "2026-01-01T00:00:00Z", + }) + .to_string(); + + let stream = client + .execute_task(SquadTask { + task_id: "t-fechamento".into(), + description: format!("crie {filename} com uma função de soma"), + decision_type: "architecture".into(), + max_autonomy_level: 3, + // Evidência PRESENTE (Fase 5 Onda 3) — ao contrário dos outros + // dois testes deste arquivo, este PRECISA exercitar + // validate_results() de verdade (é o veredito final que a + // definição de pronto exige observar). + verification_evidence_json: evidence_json, + }) + .await + .expect("ExecuteTask deveria abrir o stream"); + + let mut events: Vec = Vec::new(); + let mut stream = stream; + while let Some(ev) = stream.message().await.expect("stream de SquadEvent") { + events.push(ev); + } + core_task.abort(); + + // 1. O critério literal: o arquivo existe de verdade no workspace. + let written = std::fs::read_to_string(workspace_root.path().join(filename)) + .unwrap_or_else(|e| panic!("{filename} deveria existir no workspace: {e}")); + assert!(written.contains("Calculadora"), "conteúdo: {written}"); + + // 2. O ledger tem a entrada da escrita. + let ledger = forge_store::LedgerStore::open( + workspace_root + .path() + .join(".forge") + .join("forge.db") + .to_str() + .unwrap(), + ) + .expect("ledger deveria abrir"); + let entries = ledger + .recent(50, Some("forge-cli:squad-tool")) + .expect("recent não deveria falhar"); + assert!( + entries.iter().any(|e| e.kind == "squad.tool_run"), + "esperava uma entrada squad.tool_run no ledger, achei: {entries:?}" + ); + + // 3. Consenso real (mesmo padrão dos outros dois testes deste arquivo). + let consensus = events + .iter() + .find_map(|e| match &e.payload { + Some(squad_event::Payload::Consensus(c)) => Some(c), + _ => None, + }) + .expect("deveria haver um evento de consenso"); + assert!(!consensus.requires_human); + + // 4. O veredito final é observável fora do retorno Python descartado + // (server.py) e reflete aprovação — sobre evidência real (o assert + // dentro de ScriptedCoreWithTools::generate já teria derrubado o + // teste se a evidência não tivesse chegado ao auditor). + let final_validation = events.iter().find_map(|e| match &e.payload { + Some(squad_event::Payload::Step(s)) if s.step_id == "final_validation" => Some(s), + _ => None, + }); + let final_validation = final_validation.expect("deveria haver um StepResult final_validation"); + assert!( + final_validation.success, + "summary: {}", + final_validation.summary + ); + let summary: serde_json::Value = + serde_json::from_str(&final_validation.summary).expect("summary deveria ser JSON"); + assert_eq!(summary["approved"], serde_json::json!(true)); +} diff --git a/docs/adr/0023-runtool-ativado-squad-como-executor.md b/docs/adr/0023-runtool-ativado-squad-como-executor.md new file mode 100644 index 0000000..34ba418 --- /dev/null +++ b/docs/adr/0023-runtool-ativado-squad-como-executor.md @@ -0,0 +1,197 @@ +# ADR 0023 — `RunTool` ativado: squad como executor sob permission-engine + +- Status: aceita +- Data: 2026-07-07 + +## Contexto + +Um run real na VPS (`forge squad "crie uma calculadora científica... gere +um arquivo .html"`) não produziu arquivo nenhum. O diagnóstico (parecer +de engenharia externo, revisado e corrigido em dois pontos) apontou a +causa raiz: o squad planeja, delibera e audita, mas não tinha executor. A +**Onda 0** (commit `f8be5bd`, PR #42) fechou a metade imediata do +problema — o auditor parou de poder alegar que um arquivo foi +persistido sem evidência — e deixou explicitamente registrado em +`pendencias.md` o que faltava: "Onda 1 (RunTool real) e Onda 2 (loop +ReAct do developer)... a peça que faz um arquivo aparecer de verdade no +disco". Esta ADR fecha essa pendência (Ondas 1–3). + +**Precedente correto: `Generate`/`RequestPermission`, não a ADR 0022.** +`RunTool(ToolCall) -> ToolResult` já existia no contrato +(`schemas/proto/core.proto`), servido pelo Rust e dormente +(`Status::unimplemented`) — a mesma forma de `Generate`/ +`RequestPermission`, que já estão ativos e provados nessa direção +(`CoreService` servido pelo Rust, chamado pelo Python). A ADR 0022 +não é o precedente aqui: ela tratou de um problema com a direção +**oposta** (memória mora no Python; `CoreService.Recall`/`Remember` +seriam a direção errada) e por isso construiu um `MemoryService` novo, +servido pelo Python — decisão correta para aquele caso, mas não aplicável +a `RunTool`, que já está na direção certa desde o início. + +## Decisão + +Ativar `RunTool` reusando o `ToolRegistry`/`PermissionEngine` que o loop +de agente único já usa (`crates/forge-core/src/agent_loop.rs`) — não um +"shuttle" de `final_output` pelo stream de eventos. Zero mudança breaking +de proto (só comentários aditivos em `ToolCall`/`ToolResult`). + +**Onda 1 — executor real no Rust.** `CoreBackend` (`forge-sidecar/src/ +core_server.rs`) ganha `run_tool`; o handler gRPC vira um passthrough +fino (erro de domínio — negado/falhou — vira `ToolResult` normal, nunca +`Status` de transporte, mesmo espírito de `generate`). `core_run_tool` +(`forge-cli/src/squad.rs`) é o helper compartilhado pelos três +`CoreBackend` de produção (`GatewayCoreBackend`, `WebSquadCoreBackend`, +`ScriptedSquadCoreBackend`): recalcula o escopo real via `Tool::scope` +(o `ToolCall.scope` da rede é só informativo — nunca decide permissão, +fechando um vetor onde um Python bugado/comprometido poderia declarar +escopo mais permissivo que o real), avalia via `PermissionEngine` (perfil +`BUILD` reusado — `read`/`grep` liberados, `edit`/`bash` pedem +confirmação — perguntar a cada leitura de um loop de vários passos seria +ruído desproporcional), executa em `spawn_blocking` (só a chamada +síncrona `tool.run`, não a checagem de permissão nem o `Ask` assíncrono) +e registra cada chamada no ledger (`squad.tool_run`, via novo +`session::append_entry` — uma variante de `append_override_entry` sem a +marcação de override, para logging fora do ciclo de vida de uma +`Session` de tarefa). Convenção de `exit_code`: `0` sucesso; `1` erro de +execução/args inválidos/ferramenta desconhecida (vale tentar de novo); +`-1` negado (não adianta repetir a mesma ação) — um sinal estrutural para +o loop ReAct, não só prosa pro modelo interpretar. + +**Onda 2 — loop ReAct real no `developer` Python.** `ToolClient` +Protocol + `GrpcToolClient` (`grpc_clients.py`) sobre `CoreService. +RunTool` — zero codegen novo, o stub Python já existia gerado +(`core_pb2_grpc.py`) e nunca usado. `DeveloperAgent._implement_with_tools` +é o loop: o modelo alterna entre `tool_call` (executado de verdade) e +`final_answer`, até um dos dois ou até estourar `_MAX_REACT_STEPS`/ +`_REACT_TIMEOUT_SECONDS` — nesse caso devolve `status: "incomplete"` +honesto, nunca fabrica sucesso. Sinal de ativação: +`bool(task.get("action")) and tool_client is not None` — separa +trabalho real do plano de proposta/avaliação sem hardcodar um +vocabulário de ações, preservando o caminho de chamada única intacto. + +Achado real durante a revisão do plano, antes de qualquer linha de +código: `_can_parallelize` (`orchestrator.py`) manda um passo +`"implement"` sem `dependencies` — o caso comum de um plano de 1 passo — +para `_extract_parallel_tasks`, que chamava o developer **sem** +`"action"`. O sinal de ativação nunca disparava nesse caminho: era +exatamente a rota que reproduziria o bug original mesmo com `RunTool`/ +loop ReAct prontos. Fix: `_extract_parallel_tasks` passou a propagar +`action`/`prior_results` (mesma forma do `step_task` sequencial) — testado +com um caso que falha sem o fix (verificado manualmente revertendo e +rodando antes de reaplicar). + +Achado real, também de leitura antes de escrever código: +`core_generate` (`squad.rs`) só tratava o papel `"system"` como +especial — qualquer outro, incluindo `"assistant"`, colapsava em +`Role::User`. Todo caller anterior mandava só 1 mensagem system + 1 +user, então isso nunca foi exercitado; o loop ReAct é o primeiro a +mandar histórico multi-turno de verdade, e a API da Anthropic exige +alternância estrita. Corrigido antes de dar problema em produção. + +**Onda 3 — evidência real chega ao auditor + gate duro + observabilidade +do veredito.** Como a Onda 0 já fez `execution_results`/`prior_results` +carregarem o dict completo de cada passo, a nova chave `tool_calls` (Onda +2) chega aos dois pontos de auditoria sem nenhuma fiação nova no +orquestrador. `_claims_completion_without_write_evidence` +(`auditor.py`) é um gate duro (mesma filosofia de `forge_review/ +gates.py::evaluate` — regra dura sobrepõe a média do LLM): reprova ANTES +do gateway ser chamado quando um resultado do developer alega +`status: completed` sem nenhum `tool_calls[].exit_code == 0` em +`edit`/`bash`. Só se aplica a resultados que passaram pelo loop ReAct +(carregam a chave `tool_calls`, mesmo vazia) — um resultado do caminho +antigo de chamada única nunca teve infraestrutura de ferramenta +disponível, então gateá-lo puniria a ausência de algo que nunca poderia +ter existido (achado real ao rodar a suíte: sem esta distinção, o gate +quebrava um teste legítimo da Onda 0 que usa o caminho antigo de +propósito). Um novo `_emit` em `orchestrator.py` (reusando `StepResult`, +`step_id: "final_validation"`, zero mudança de proto) torna o veredito +final observável fora do dict de retorno que `server.py` descarta — +sem isso, a definição de pronto ("o auditor julgando sobre o arquivo +real, observável") não seria verificável fora de um teste Python isolado. + +## Não-escopo explícito + +- Sem ferramenta de criação de arquivo dedicada. `ToolRegistry:: + default_set` = `read, grep, edit, bash`; `EditTool` exige que o arquivo + já exista (`std::fs::read_to_string` antes de escrever). `bash` + (heredoc/redirecionamento) é o único mecanismo para criar um arquivo do + zero — guiado por prompt (`_REACT_SYSTEM_PROMPT`), não uma ferramenta + nova. Documentado como restrição real, não contornado em silêncio. +- Sem mensagem de proto dedicada para o evento de validação final — reusa + `StepResult` (`kind: "step"`, `step_id: "final_validation"`). +- Sem perfil de permissão mais fino que `BUILD` para o squad + especificamente — a alternativa mais conservadora + (`PermissionEngine::default()`, pergunta tudo) é uma troca de uma linha + se a postura mais cautelosa for preferida no futuro. +- **O gate duro é backstop mecânico, não prova de materialização** — prova + "uma chamada mutante (`edit`/`bash`) rodou sem erro do lado Rust", não + "o arquivo X existe com o conteúdo Y". `BashTool::run` devolve `Ok` + mesmo quando o comando shell interno falha (embute `[exit code: N]` + como texto, não retorna `Err`) — então mesmo o `exit_code: 0` do proto + é sobre a execução da *ferramenta*, não sobre o sucesso do *comando*. + A convenção de comando de verificação do prompt (ex.: `sha256sum` antes + do `final_answer`) é o que bota evidência de verdade no transcript para + o auditor-LLM raciocinar em cima; o gate mecânico fica deliberadamente + grosseiro para não depender de parsing frágil de texto. Complementar à + proibição textual dos dois prompts da Onda 0, não substitui: o prompt + reduz a chance de uma alegação falsa chegar ao payload; o gate faz uma + alegação falsa não conseguir produzir `approved`/`passed: true` de jeito + nenhum, mas só quando há infraestrutura de ferramenta para checar. +- Sem extração estrutural de path/hash do lado Rust para arquivos criados + via `bash` — só a convenção de prompt acima. +- `max_autonomy_level` (ADR 0021) continua intocado e ignorado + ponta-a-ponta — ortogonal a esta entrega. +- Sem mudança nos jobs `sandbox`/`bench`/`k6`/`deny`/`security`/`web` do + CI — os jobs `rust` e `python` (que já rodam `cargo test --workspace`/ + `uv run pytest`) pegam os testes novos automaticamente. + +## O que foi provado, não só declarado + +- **Onda 1** — `crates/forge-sidecar/tests/core_server_inprocess.rs`: + `run_tool_executa_de_verdade_e_arquivo_aparece_no_disco` (um `ToolCall` + de `bash` sobre UDS puro, sem Python, cria um arquivo real num tempdir) + e `run_tool_negado_pela_permissao_nao_executa` (uma negação do motor de + permissões não executa nada, `exit_code: -1`, nenhum arquivo criado). +- **Onda 2** — `python/packages/forge-squad/tests/test_developer.py`: + `test_execute_com_action_usa_tool_client_e_faz_tool_call_antes_do_final_answer`, + `test_execute_sem_action_nao_usa_tools_mesmo_com_tool_client_anexado` + (prova o sinal de ativação e que o caminho antigo sobrevive intacto), + `test_react_loop_esgota_passos_sem_final_answer_devolve_incomplete_honesto`. + `test_orchestrator.py::test_passo_implement_paralelizavel_ainda_ativa_tool_client_do_developer` + prova o fix do caminho paralelo (falha sem ele — verificado manualmente). + `crates/forge-cli/src/squad.rs` — teste inline + `core_generate_mapeia_papel_assistant_para_role_assistant` prova o fix + de papel via um gerador que grava as mensagens recebidas. +- **Onda 3** — `python/packages/forge-squad/tests/test_auditor.py`: + `test_validate_results_reprova_completed_sem_tool_call_de_escrita_mesmo_com_llm_aprovando` + e `test_audit_reprova_prior_results_sem_tool_call_de_escrita_mesmo_com_llm_aprovando` + (o gate dispara antes do gateway ser sequer chamado — `gateway.requests + == []`), `test_validate_results_nao_reprova_completed_com_tool_call_de_escrita_bem_sucedida` + e `test_validate_results_nao_reprova_completed_sem_chave_tool_calls_caminho_antigo` + (as duas contraprovas: o gate não é um martelo cego). O teste de + fechamento, `crates/forge-sidecar/tests/squad_e2e.rs:: + squad_cria_arquivo_real_via_run_tool_ledger_e_auditor_veem_evidencia` + — processo Python real, sem key — dirige `forge squad "crie + scientific-calculator.html..."` e prova, nesta ordem: (1) o arquivo + existe de verdade no workspace; (2) o ledger tem a entrada + `squad.tool_run`; (3) um evento `Consensus` real aparece; (4) um + `StepResult{step_id: "final_validation"}` aparece com `approved: true` + — e o próprio backend roteirizado falha o teste alto e claro (via + `assert!` dentro do `generate()` do lado "auditor") se o payload que + chega ao Rust não carregar evidência real de `tool_calls`, provando que + o auditor não está julgando no vácuo. + +## Consequências + +- O squad passa a tocar o filesystem — expande, não cria, uma fronteira + de confiança: é o mesmo `PermissionEngine` que o Rust já aplica ao loop + de agente único, agora também sob chamada do Python. +- Latência maior no developer quando o loop ReAct roda (multi-turno vs. + chamada única) — aceitável, é o preço de materializar algo de verdade. +- O ledger cresce uma entrada por chamada de ferramenta do squad. +- Trabalho futuro explícito (não bloqueante): ferramenta de escrita + dedicada (criar arquivo sem depender de heredoc via `bash`); perfil de + permissão mais fino especificamente para o squad; evento de proto + dedicado para validação (`ValidationResult`) em vez de reusar + `StepResult`; extração estrutural de evidência (path/hash) do lado + Rust para escritas via `bash`. diff --git a/pendencias.md b/pendencias.md index 7b9473f..dd0a3eb 100644 --- a/pendencias.md +++ b/pendencias.md @@ -1341,3 +1341,24 @@ ADR 0019, sem decisão em aberto que precisasse deste arquivo. pelo parecer (e que endosso): `forge squad "crie X.html..."` produzindo `X.html` real no workspace, registrado no ledger, com o auditor julgando sobre um artefato que existe — não sobre uma alegação de texto. +- **[resolvido] Onda 1 (RunTool) + Onda 2 (loop ReAct) + Onda 3 (evidência + real ao auditor) fechadas — ADR 0023.** `RunTool` ativado no Rust + (`core_run_tool`, os três `CoreBackend` de produção); `DeveloperAgent. + _implement_with_tools` é o loop ReAct real; o gate duro em + `auditor.py` reprova "completed" sem evidência de escrita ANTES do + gateway ser chamado. Dois achados reais durante a implementação, não + previstos no parecer original: (1) o caminho paralelo do orquestrador + (`_extract_parallel_tasks`, disparado por qualquer passo "implement" + sem `dependencies` — o caso comum) chamava o developer sem `"action"`, + então o sinal de ativação do loop ReAct nunca disparava ali — corrigido + antes de virar uma regressão silenciosa; (2) `core_generate` só tratava + o papel `"system"`, colapsando `"assistant"` em `Role::User` — o loop + ReAct é o primeiro caller a mandar histórico multi-turno de verdade e + teria esbarrado nisso contra um provider real. Definição de pronto + provada ponta a ponta com processo Python real, sem key + (`crates/forge-sidecar/tests/squad_e2e.rs:: + squad_cria_arquivo_real_via_run_tool_ledger_e_auditor_veem_evidencia`): + o arquivo existe no workspace, o ledger tem `squad.tool_run`, e o + `StepResult{step_id:"final_validation"}` aprovado é observável fora do + retorno Python que `server.py` descartava. Detalhes completos e o + raciocínio de cada decisão em `docs/adr/0023-runtool-ativado-squad-como-executor.md`. diff --git a/python/packages/forge-squad/src/forge_squad/agents/auditor.py b/python/packages/forge-squad/src/forge_squad/agents/auditor.py index e382d46..8d55bd3 100644 --- a/python/packages/forge-squad/src/forge_squad/agents/auditor.py +++ b/python/packages/forge-squad/src/forge_squad/agents/auditor.py @@ -19,6 +19,21 @@ disparar a tarefa. `check_security`/`check_quality` continuam (baratos, complementares); a evidência do `/verify` é entrada adicional para o gateway, nunca decisão automática — o veredito ainda vem do modelo. + +Onda 3 ("tool execution architecture"): com `RunTool`/o loop ReAct do +developer ativos, `execution_results` passa a carregar `tool_calls` reais +por passo. `_claims_completion_without_write_evidence` é um gate duro +(mesma filosofia de `forge_review/gates.py::evaluate` — regra dura +sobrepõe a média do LLM): um developer "completed" sem nenhum tool_call +mutante (`edit`/`bash`) bem-sucedido no transcript é reprovado ANTES do +gateway ser chamado, independente do que o modelo diria. Limitação +honesta: o gate prova "uma chamada mutante rodou sem erro", não "o +arquivo X existe com o conteúdo Y" — heredoc de `bash` não devolve nada +estruturado no stdout; fica deliberadamente grosseiro para não depender +de parsing frágil de texto. É complementar à proibição textual dos dois +prompts abaixo (Onda 0), não substitui: o prompt reduz a chance de uma +alegação falsa chegar ao payload; o gate faz uma alegação falsa não +conseguir produzir `approved: true`/`passed: true` de jeito nenhum. """ from __future__ import annotations @@ -63,6 +78,43 @@ ("subprocess", "Process spawning risk"), ] +#: Ferramentas que mutam o filesystem — a única evidência mecânica que o +#: gate duro aceita como lastro para um "completed" do developer. +_MUTATING_TOOLS = {"edit", "bash"} + +_GATE_ISSUE = ( + "developer reportou 'completed' sem nenhuma chamada de ferramenta " + "mutante (edit/bash) bem-sucedida no transcript — gate duro, " + "reprovado antes do veredito do modelo" +) + + +def _claims_completion_without_write_evidence(results: list[dict[str, Any]]) -> bool: + """`True` quando algum resultado do developer alega `status: completed` + sem nenhum `tool_calls[].exit_code == 0` em `edit`/`bash` — a alegação + não tem lastro mecânico de escrita. + + Só se aplica a resultados que passaram pelo loop ReAct (carregam a + chave `tool_calls` — mesmo vazia). Um resultado do caminho antigo de + chamada única (`_parse_result`, sem `tool_client` anexado — proposta/ + avaliação em `_get_squad_proposals`, ou qualquer chamador que não + anexou `tool_client`) nunca teve infraestrutura de ferramenta + disponível; gatear "completed" ali seria punir a ausência de algo que + nunca poderia ter existido, não uma alegação vazia de verdade.""" + + for r in results: + if not isinstance(r, dict) or r.get("agent") != "developer" or r.get("status") != "completed": + continue + if "tool_calls" not in r: + continue + tool_calls = r.get("tool_calls") or [] + if not any( + isinstance(tc, dict) and tc.get("tool") in _MUTATING_TOOLS and tc.get("exit_code") == 0 + for tc in tool_calls + ): + return True + return False + class AuditorAgent(BaseAgent): """Especialista em segurança e qualidade com veredito real via gateway.""" @@ -96,6 +148,10 @@ async def audit(self, task: dict[str, Any]) -> dict[str, Any]: "AuditorAgent sem gateway anexado — chame attach_gateway() antes de execute()" ) + prior_results = task.get("prior_results") or [] + if _claims_completion_without_write_evidence(prior_results): + return {"issues": [], "warnings": [], "passed": False, "confidence": 0.0, "notes": _GATE_ISSUE, "additional_checks": []} + issues = self.check_security(task.get("code", "")) if task.get("code") else [] warnings = self.check_quality(task.get("metrics", {})) if task.get("metrics") else [] @@ -141,6 +197,9 @@ async def validate_results( "AuditorAgent sem gateway anexado — chame attach_gateway() antes de execute()" ) + if _claims_completion_without_write_evidence(results): + return {"approved": False, "confidence": 0.0, "issues": [_GATE_ISSUE], "agent_scores": {}} + payload: dict[str, Any] = {"results": results} if evidence is not None: payload["verification_evidence"] = evidence diff --git a/python/packages/forge-squad/src/forge_squad/orchestrator.py b/python/packages/forge-squad/src/forge_squad/orchestrator.py index 65bdfd3..aebb70b 100644 --- a/python/packages/forge-squad/src/forge_squad/orchestrator.py +++ b/python/packages/forge-squad/src/forge_squad/orchestrator.py @@ -24,6 +24,7 @@ from __future__ import annotations +import json import logging import uuid from datetime import datetime, timezone @@ -164,6 +165,28 @@ async def execute_complex_task( ) overall_success = bool(final_validation.get("approved", False)) + # Onda 3 ("tool execution architecture"): sem isto, o veredito final + # (calculado sobre evidência real de tool_calls) não era observável + # em nenhum lugar fora do dict de retorno que `server.py` descarta + # (`run()`, ExecuteTask) — inobservável mesmo depois de RunTool/loop + # ReAct existirem. Reusa StepResult (`kind: "step"`), zero mudança + # de proto; `step_id` fixo distingue este evento de um passo real. + await self._emit( + { + "kind": "step", + "step_id": "final_validation", + "success": overall_success, + "summary": json.dumps( + { + "approved": overall_success, + "confidence": final_validation.get("confidence"), + "issues": final_validation.get("issues", []), + }, + ensure_ascii=False, + ), + } + ) + # ADR 0006: o portão não registra mais; quem executa registra o # resultado REAL da execução (a menos que já tenha sido rejeitado # acima, caso em que retornamos antes daqui). diff --git a/python/packages/forge-squad/tests/test_auditor.py b/python/packages/forge-squad/tests/test_auditor.py index d2d91bc..5e2db91 100644 --- a/python/packages/forge-squad/tests/test_auditor.py +++ b/python/packages/forge-squad/tests/test_auditor.py @@ -188,6 +188,80 @@ def test_audit_sem_prior_results_nao_inclui_a_chave_no_payload(): assert "prior_agent_results" not in sent_content +def test_validate_results_reprova_completed_sem_tool_call_de_escrita_mesmo_com_llm_aprovando(): + # Gate duro (Onda 3, "tool execution architecture"): "completed" sem + # nenhum tool_call de escrita bem-sucedida é reprovado ANTES do + # gateway ser sequer considerado — mesmo que o LLM roteirizado + # aprovaria se fosse chamado. Prova que o gate não depende do modelo. + payload = {"approved": True, "confidence": 0.9, "issues": [], "agent_scores": {}} + gateway = ScriptedGatewayClient([LlmResponse(text=json.dumps(payload))]) + agent = AuditorAgent() + agent.attach_gateway(gateway) + + results = [{"agent": "developer", "status": "completed", "final_output": "x", "tool_calls": []}] + validation = asyncio.run(agent.validate_results(results)) + + assert validation["approved"] is False + assert gateway.requests == [] # o gateway nunca foi chamado + + +def test_audit_reprova_prior_results_sem_tool_call_de_escrita_mesmo_com_llm_aprovando(): + payload = {"passed": True, "confidence": 0.9, "notes": "ok", "additional_checks": []} + gateway = ScriptedGatewayClient([LlmResponse(text=json.dumps(payload))]) + agent = AuditorAgent() + agent.attach_gateway(gateway) + + prior = [{"agent": "developer", "status": "completed", "final_output": "x", "tool_calls": []}] + assessment = asyncio.run(agent.audit({"description": "validar arquivo gerado", "prior_results": prior})) + + assert assessment["passed"] is False + assert gateway.requests == [] + + +def test_validate_results_nao_reprova_completed_sem_chave_tool_calls_caminho_antigo(): + # Achado real ao rodar a suíte após adicionar o gate: um resultado do + # caminho de chamada única (`_parse_result`, sem `tool_client` + # anexado — nunca teve infraestrutura de ferramenta disponível) não + # carrega a chave `tool_calls` nenhuma. Gatear esse caso puniria a + # ausência de algo que nunca poderia ter existido — o gate só se + # aplica a resultados que passaram pelo loop ReAct (chave presente, + # mesmo vazia). Sem esta distinção, este teste (e o "sem RunTool + # ainda" mais amplo) regride. + payload = {"approved": True, "confidence": 0.9, "issues": [], "agent_scores": {}} + gateway = ScriptedGatewayClient([LlmResponse(text=json.dumps(payload))]) + agent = AuditorAgent() + agent.attach_gateway(gateway) + + results = [{"agent": "developer", "status": "completed", "final_output": "x"}] # sem "tool_calls" + validation = asyncio.run(agent.validate_results(results)) + + assert validation["approved"] is True + assert len(gateway.requests) == 1 + + +def test_validate_results_nao_reprova_completed_com_tool_call_de_escrita_bem_sucedida(): + # Contraprova: o gate não é um martelo cego contra "completed" — uma + # chamada mutante real e bem-sucedida no transcript deixa o veredito + # a cargo do modelo normalmente. + payload = {"approved": True, "confidence": 0.9, "issues": [], "agent_scores": {}} + gateway = ScriptedGatewayClient([LlmResponse(text=json.dumps(payload))]) + agent = AuditorAgent() + agent.attach_gateway(gateway) + + results = [ + { + "agent": "developer", + "status": "completed", + "final_output": "x", + "tool_calls": [{"tool": "bash", "exit_code": 0, "content": "sha256: abc123"}], + } + ] + validation = asyncio.run(agent.validate_results(results)) + + assert validation["approved"] is True + assert len(gateway.requests) == 1 + + def test_resposta_sem_json_cai_no_fallback_honesto_nao_aprovado(): agent = AuditorAgent() agent.attach_gateway(ScriptedGatewayClient([LlmResponse(text="não consigo avaliar isso.")]))