Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

48 changes: 37 additions & 11 deletions crates/forge-cli/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<OverrideMark>,
) -> anyhow::Result<()> {
let dir = root.join(".forge");
std::fs::create_dir_all(&dir)?;
Expand All @@ -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::*;
Expand Down
252 changes: 243 additions & 9 deletions crates/forge-cli/src/squad.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,165 @@
use crate::session::{now_rfc3339, Session};
use crate::{run_once, RunOpts};
use anyhow::Result;
use forge_llm::chat::{ChatMessage, GenerateRequest};
use forge_core::{Decision, PermissionEngine};
use forge_llm::chat::{ChatMessage, ContentBlock, GenerateRequest, Role};
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<F, Fut>(
tools: &Arc<ToolRegistry>,
permissions: &PermissionEngine,
call: &ToolCall,
root: &Path,
ask: F,
) -> ToolResult
where
F: FnOnce(PermissionRequest) -> Fut,
Fut: std::future::Future<Output = bool>,
{
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<G: Generator> {
generator: Arc<G>,
auto_yes: bool,
root: PathBuf,
tools: Arc<ToolRegistry>,
tool_permissions: PermissionEngine,
}

#[derive(serde::Deserialize)]
Expand All @@ -45,13 +188,24 @@ pub(crate) async fn core_generate<G: Generator>(
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 {
Expand Down Expand Up @@ -108,6 +262,17 @@ impl<G: Generator + Send + Sync + 'static> CoreBackend for GatewayCoreBackend<G>
.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
Expand Down Expand Up @@ -153,6 +318,9 @@ pub async fn run_squad<G: Generator + Send + Sync + 'static>(
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 {
Expand Down Expand Up @@ -349,3 +517,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<Vec<Vec<ChatMessage>>>,
}

impl Generator for RecordingGenerator {
async fn generate(
&self,
req: GenerateRequest,
_on_delta: &mut (dyn FnMut(&str) + Send),
) -> Result<AssistantTurn, GatewayError> {
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);
}
}
Loading
Loading