From 9c54f238cdfa3332c22250c48ef6798ec7fef65d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 18:10:03 +0000 Subject: [PATCH] =?UTF-8?q?feat(squad):=20usu=C3=A1rio=20como=20membro=20d?= =?UTF-8?q?a=20squad=20=E2=80=94=20chat=20ao=20vivo=20(Fase=201a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transforma o humano de aprovador-de-gate em MEMBRO participante da squad, com um chat ao vivo ao lado da esteira. Contrato (aditivo, ADR de contrato preservado): - squad.proto: nova ChatMessage (author/author_role/text/in_reply_to) na variante 9 do oneof payload de SquadEvent. Stubs Python regenerados. Rust (crates/forge-cli): - SquadHub.push_user_message: injeta a fala do usuário na tarefa viva — ecoa como ChatMessage(HUMAN) no stream e enfileira na inbox (base para a Fase 1b consumir como turno real do membro humano). - POST /api/squad/:id/message (202 sem corpo, mesmo cuidado do bug de 202 da Onda 15); renderização do chat no CLI squad.rs. Python (forge-squad): - orchestrator dá VOZ aos agentes: _emit_chat narra cada proposta (texto derivado do conteúdo REAL, nada fabricado) e o consenso; server.py mapeia o kind "chat" para ChatMessage. Web: - postSquadMessage + tipo SquadChatMessage; painel de conversa na tela Squad (bolhas HUMAN à direita, agentes/SYSTEM à esquerda) + input de mensagem. Testes: Rust (push_user_message_*), Python (test_chat_message_atravessa_o_ mapeamento_proto, test_event_sink_* atualizado), web (tsc/lint/vitest verdes). Fase 1b (AwaitUserTurn — a fala vira Proposal dentro do consenso) fica para PR seguinte; registrada em pendencias.md. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ep37vHQeEet5LynX35nY2G --- crates/forge-cli/src/squad.rs | 1 + crates/forge-cli/src/squad_agent.rs | 118 +++++++++++++++++- pendencias.md | 46 +++++++ .../src/forge_proto/squad_pb2.py | 42 ++++--- .../src/forge_proto/squad_pb2.pyi | 18 ++- .../src/forge_squad/orchestrator.py | 49 ++++++++ .../forge-squad/src/forge_squad/server.py | 9 ++ .../forge-squad/tests/test_orchestrator.py | 17 ++- .../forge-squad/tests/test_squad_server.py | 12 ++ schemas/proto/squad.proto | 12 ++ web/src/api/squad.ts | 26 +++- web/src/components/screens/user/Squad.tsx | 91 ++++++++++++++ 12 files changed, 411 insertions(+), 30 deletions(-) diff --git a/crates/forge-cli/src/squad.rs b/crates/forge-cli/src/squad.rs index f0dbcee..2068899 100644 --- a/crates/forge-cli/src/squad.rs +++ b/crates/forge-cli/src/squad.rs @@ -337,6 +337,7 @@ fn render_event(ev: &forge_proto::squad::SquadEvent, session: &mut Session) { ); } Some(squad_event::Payload::Error(e)) => eprintln!(" ✗ erro do squad: {e}"), + Some(squad_event::Payload::Chat(c)) => eprintln!(" 💬 {}: {}", c.author, c.text), None => {} } } diff --git a/crates/forge-cli/src/squad_agent.rs b/crates/forge-cli/src/squad_agent.rs index c3e77a4..8c1c54d 100644 --- a/crates/forge-cli/src/squad_agent.rs +++ b/crates/forge-cli/src/squad_agent.rs @@ -27,10 +27,10 @@ use axum::{Json, Router}; use forge_llm::gateway::Generator; use forge_proto::core::PermissionRequest; use forge_proto::llm::{LlmRequest, Usage}; -use forge_proto::squad::{squad_event, SquadEvent, SquadTask}; +use forge_proto::squad::{squad_event, ChatMessage, SquadEvent, SquadTask}; use forge_sidecar::{serve_core, CoreBackend, SidecarError, SquadPool}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use std::convert::Infallible; use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; @@ -55,6 +55,11 @@ struct SquadTaskState { /// via e2e (`run_squad_via_http_com_gate_hitl_real_e_ledger`). tx: Option>, pending: Option, + /// Mensagens do usuário ainda não consumidas por um ponto de consulta do + /// orquestrador. Na Fase 1a servem de trilha visível (ecoadas como + /// `ChatMessage`); a Fase 1b (`AwaitUserTurn`) as puxa como turno real do + /// membro humano dentro do orquestrador. + inbox: VecDeque, } impl SquadTaskState { @@ -64,10 +69,26 @@ impl SquadTaskState { log: Vec::new(), tx: Some(tx), pending: None, + inbox: VecDeque::new(), } } } +/// Monta um `SquadEvent` de chat (variante `ChatMessage` do proto) — usado +/// para ecoar a fala do usuário e as falas narradas dos agentes na conversa. +fn chat_event(task_id: &str, author: &str, author_role: &str, text: String) -> SquadEvent { + SquadEvent { + task_id: task_id.to_string(), + ts: now_rfc3339(), + payload: Some(squad_event::Payload::Chat(ChatMessage { + author: author.to_string(), + author_role: author_role.to_string(), + text, + in_reply_to: String::new(), + })), + } +} + /// Estado compartilhado de todas as tarefas de squad vivas — publica /// eventos crus do proto, mantém o gate HITL pendente (sobrevive a /// navegador fechado, reemitido via snapshot a quem conectar depois, mesmo @@ -172,6 +193,33 @@ impl SquadHub { let _ = pending.responder.send(allow); Ok(()) } + + /// Registra uma mensagem do usuário na tarefa (o humano como MEMBRO da + /// squad). Enfileira na `inbox` (para a Fase 1b puxar como turno real) e + /// ecoa a fala como `ChatMessage` no stream, para todos os assinantes + /// verem — mesma UX de qualquer outro membro. `Err` se a tarefa não + /// existe (ex.: id inválido ou já drenada e removida). + pub fn push_user_message(&self, task_id: &str, text: String) -> Result<(), ()> { + { + let mut tasks = self.tasks.lock().expect("squad hub mutex poisoned"); + let Some(state) = tasks.get_mut(task_id) else { + return Err(()); + }; + state.inbox.push_back(text.clone()); + } + // `publish` já grava no log + faz broadcast; fora do lock acima para + // não reentrar no mutex. + self.publish(task_id, chat_event(task_id, "Você", "HUMAN", text)); + Ok(()) + } + + /// Retira a próxima mensagem do usuário pendente, se houver (consumida + /// pelo ponto de consulta do orquestrador na Fase 1b). Não bloqueia. + #[cfg_attr(not(test), allow(dead_code))] + fn take_user_message(&self, task_id: &str) -> Option { + let mut tasks = self.tasks.lock().expect("squad hub mutex poisoned"); + tasks.get_mut(task_id).and_then(|s| s.inbox.pop_front()) + } } /// `CoreBackend` real do agente web: `Generate` passa pelo `Gateway`/rate @@ -513,6 +561,41 @@ async fn resolve_hitl_handler( } } +#[derive(Deserialize)] +struct PostMessageBody { + text: String, +} + +/// O usuário como MEMBRO da squad: injeta uma mensagem na tarefa viva. A fala +/// é ecoada no stream (todos veem) e enfileirada para o orquestrador. Responde +/// `202 Accepted` **sem corpo** — o cliente não deve chamar `.json()` (mesmo +/// cuidado do bug de `202` corrigido na Onda 15). +async fn post_message_handler( + State(state): State, + Path(task_id): Path, + Json(body): Json, +) -> Response { + let text = body.text.trim().to_string(); + if text.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(ErrorBody::new("empty_message", "mensagem vazia")), + ) + .into_response(); + } + match state.hub.push_user_message(&task_id, text) { + Ok(()) => StatusCode::ACCEPTED.into_response(), + Err(()) => ( + StatusCode::NOT_FOUND, + Json(ErrorBody::new( + "task_not_found", + "tarefa de squad inexistente ou já encerrada", + )), + ) + .into_response(), + } +} + /// Router aditivo do squad ao vivo — `.merge()`ado ao router do agente web /// (mesma composição de `web_agent::merged_router`, mesma guarda de /// `Origin`/`Host`). @@ -521,6 +604,7 @@ pub fn router(hub: SquadHub, pool: Arc) -> Router { .route("/api/squad/run", post(run_squad_handler)) .route("/api/squad/{task_id}/events", get(squad_sse_handler)) .route("/api/squad/{task_id}/hitl", post(resolve_hitl_handler)) + .route("/api/squad/{task_id}/message", post(post_message_handler)) .with_state(SquadAgentState { hub, pool }) } @@ -623,6 +707,36 @@ mod tests { assert!(!hub.request_hitl("t1").await); } + #[test] + fn push_user_message_ecoa_chat_no_stream_e_enfileira() { + let hub = SquadHub::new(Duration::from_millis(100)); + let _ = hub.subscribe("t1"); + assert!(hub + .push_user_message("t1", "priorize o tom formal".into()) + .is_ok()); + // A mensagem foi ecoada como ChatMessage(HUMAN) no snapshot... + let (snapshot, _rx) = hub.subscribe("t1"); + let chat = snapshot.iter().find_map(|e| match &e.payload { + Some(squad_event::Payload::Chat(c)) => Some(c), + _ => None, + }); + let chat = chat.expect("esperava um ChatMessage no stream"); + assert_eq!(chat.author_role, "HUMAN"); + assert_eq!(chat.text, "priorize o tom formal"); + // ...e ficou enfileirada para o ponto de consulta do orquestrador. + assert_eq!( + hub.take_user_message("t1").as_deref(), + Some("priorize o tom formal") + ); + assert!(hub.take_user_message("t1").is_none()); + } + + #[test] + fn push_user_message_em_tarefa_inexistente_devolve_err() { + let hub = SquadHub::new(Duration::from_millis(100)); + assert!(hub.push_user_message("nao-existe", "oi".into()).is_err()); + } + #[tokio::test] async fn request_hitl_resolvido_true_devolve_true() { let hub = SquadHub::new(Duration::from_secs(5)); diff --git a/pendencias.md b/pendencias.md index 7b9473f..0333d76 100644 --- a/pendencias.md +++ b/pendencias.md @@ -1341,3 +1341,49 @@ 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. + +--- + +# BuildToValue — incorporação dos repos irmãos (jul/2026, autônomo) + +> Contexto: renomeação Forge→BuildToValue + roadmap em 4 fases derivado da +> análise de 11 repositórios irmãos. Handoff completo em +> `docs/handoff/desenvolvimento/HANDOFF-BUILDTOVALUE.md`. Este bloco registra +> as DÚVIDAS e decisões que precisam de revisão humana. + +## Fase 1 — usuário como membro da squad + +- **[entregue · Fase 1a] Chat visível + canal de entrada.** `ChatMessage` no + `squad.proto` (tag 9, aditivo); `POST /api/squad/:id/message` injeta a fala do + usuário (ecoada como `ChatMessage` HUMAN + enfileirada na `inbox`); o + orquestrador dá voz aos agentes (`_emit_chat`, narração derivada do conteúdo + REAL da proposta, nada fabricado) e narra o consenso; UI de conversa na tela + Squad. Testes: Rust (`push_user_message_*`), Python (`test_chat_message_*`, + `test_event_sink_*`), web (tsc/lint/vitest verdes). + +- **[DÚVIDA — decisão do dono · Fase 1b] O usuário influencia o run de verdade?** + Hoje (1a) a fala do usuário é visível e enfileirada, mas o orquestrador é um + pipeline determinístico (plano→propostas→consenso→passos) e **ainda não + consome** a `inbox` no meio da execução. Para o usuário virar membro PLENO + (a fala vira `Proposal` de peso máximo dentro do consenso), é preciso: + (a) RPC novo `CoreService.AwaitUserTurn` (`core.proto`), (b) método no trait + `CoreBackend` (`forge-sidecar/core_server.rs`) com default no-op p/ não quebrar + os backends existentes, (c) `WebSquadCoreBackend.await_user_turn` puxando de + `SquadHub.take_user_message`, (d) `UserAgent` + ponto de consulta no + `_get_squad_proposals`. É mais invasivo (mexe no contrato gRPC Core) — deixei + como **PR 2 separada** por segurança. **Confirmar se quer 1b agora** ou se 1a + (chat + gate) já atende "não só aprovador". + +## Fase 3/4 — decisão estratégica pendente + +- **[DÚVIDA — decisão do dono] Autonomia progressiva L1–L5.** A análise recomenda + MANTER descopada (ADR 0021): o próprio SquadIAds não usa o loop dele, e a UX + "humano é membro contínuo" torna a auto-promoção redundante. Mas era um dos 4 + diferenciais do BuildToValue original. Implementei/implementarei autonomia como + **rótulo descritivo** (metadado consultável em `persona.v1`), NÃO como loop + automático. Se quiser readotar um dial Manual/Assistido/Autônomo de verdade, é + um **ADR novo explícito** que supersede o 0021 — não farei em silêncio. + +- **[nota] Descopes herdados que sigo respeitando:** `max_autonomy_level` continua + ignorado ponta-a-ponta (ADR 0021); `forge_squad/forgetting.py` segue código + morto. Não vou "ligar o campo" sem efeito real. diff --git a/python/packages/forge-proto-py/src/forge_proto/squad_pb2.py b/python/packages/forge-proto-py/src/forge_proto/squad_pb2.py index eaf1e8f..ac9d030 100644 --- a/python/packages/forge-proto-py/src/forge_proto/squad_pb2.py +++ b/python/packages/forge-proto-py/src/forge_proto/squad_pb2.py @@ -24,7 +24,7 @@ -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0bsquad.proto\x12\x0e\x66orge.squad.v1\"\x88\x01\n\tSquadTask\x12\x0f\n\x07task_id\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x15\n\rdecision_type\x18\x03 \x01(\t\x12\x1a\n\x12max_autonomy_level\x18\x04 \x01(\r\x12\"\n\x1averification_evidence_json\x18\x05 \x01(\t\"\xab\x02\n\nSquadEvent\x12\x0f\n\x07task_id\x18\x01 \x01(\t\x12\n\n\x02ts\x18\x02 \x01(\t\x12,\n\x08proposal\x18\x03 \x01(\x0b\x32\x18.forge.squad.v1.ProposalH\x00\x12.\n\tconsensus\x18\x04 \x01(\x0b\x32\x19.forge.squad.v1.ConsensusH\x00\x12*\n\x07handoff\x18\x05 \x01(\x0b\x32\x17.forge.squad.v1.HandoffH\x00\x12.\n\x04hitl\x18\x06 \x01(\x0b\x32\x1e.forge.squad.v1.HitlEscalationH\x00\x12*\n\x04step\x18\x07 \x01(\x0b\x32\x1a.forge.squad.v1.StepResultH\x00\x12\x0f\n\x05\x65rror\x18\x08 \x01(\tH\x00\x42\t\n\x07payload\"C\n\x08Proposal\x12\r\n\x05\x61gent\x18\x01 \x01(\t\x12\x12\n\nconfidence\x18\x02 \x01(\x01\x12\x14\n\x0c\x63ontent_json\x18\x03 \x01(\t\"d\n\tConsensus\x12\x16\n\x0e\x64\x65\x63ision_maker\x18\x01 \x01(\t\x12\x10\n\x08strength\x18\x02 \x01(\x01\x12\x15\n\rdecision_json\x18\x03 \x01(\t\x12\x16\n\x0erequires_human\x18\x04 \x01(\x08\"\xd4\x01\n\x07Handoff\x12,\n\x05phase\x18\x01 \x01(\x0e\x32\x1d.forge.squad.v1.Handoff.Phase\x12\x12\n\nfrom_agent\x18\x02 \x01(\t\x12\x10\n\x08to_agent\x18\x03 \x01(\t\x12\x10\n\x08\x63ontract\x18\x04 \x01(\t\x12\x16\n\x0epayload_digest\x18\x05 \x01(\t\"K\n\x05Phase\x12\x15\n\x11PHASE_UNSPECIFIED\x10\x00\x12\t\n\x05START\x10\x01\x12\x07\n\x03\x41\x43K\x10\x02\x12\x0c\n\x08\x43OMPLETE\x10\x03\x12\t\n\x05\x45RROR\x10\x04\"4\n\x0eHitlEscalation\x12\x0e\n\x06reason\x18\x01 \x01(\t\x12\x12\n\nconfidence\x18\x02 \x01(\x01\"?\n\nStepResult\x12\x0f\n\x07step_id\x18\x01 \x01(\t\x12\x0f\n\x07success\x18\x02 \x01(\x08\x12\x0f\n\x07summary\x18\x03 \x01(\t\"\x0f\n\rHealthRequest\"0\n\x0eHealthResponse\x12\r\n\x05ready\x18\x01 \x01(\x08\x12\x0f\n\x07version\x18\x02 \x01(\t2\x9f\x01\n\x0cSquadService\x12\x46\n\x0b\x45xecuteTask\x12\x19.forge.squad.v1.SquadTask\x1a\x1a.forge.squad.v1.SquadEvent0\x01\x12G\n\x06Health\x12\x1d.forge.squad.v1.HealthRequest\x1a\x1e.forge.squad.v1.HealthResponseb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0bsquad.proto\x12\x0e\x66orge.squad.v1\"\x88\x01\n\tSquadTask\x12\x0f\n\x07task_id\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x15\n\rdecision_type\x18\x03 \x01(\t\x12\x1a\n\x12max_autonomy_level\x18\x04 \x01(\r\x12\"\n\x1averification_evidence_json\x18\x05 \x01(\t\"\xd8\x02\n\nSquadEvent\x12\x0f\n\x07task_id\x18\x01 \x01(\t\x12\n\n\x02ts\x18\x02 \x01(\t\x12,\n\x08proposal\x18\x03 \x01(\x0b\x32\x18.forge.squad.v1.ProposalH\x00\x12.\n\tconsensus\x18\x04 \x01(\x0b\x32\x19.forge.squad.v1.ConsensusH\x00\x12*\n\x07handoff\x18\x05 \x01(\x0b\x32\x17.forge.squad.v1.HandoffH\x00\x12.\n\x04hitl\x18\x06 \x01(\x0b\x32\x1e.forge.squad.v1.HitlEscalationH\x00\x12*\n\x04step\x18\x07 \x01(\x0b\x32\x1a.forge.squad.v1.StepResultH\x00\x12\x0f\n\x05\x65rror\x18\x08 \x01(\tH\x00\x12+\n\x04\x63hat\x18\t \x01(\x0b\x32\x1b.forge.squad.v1.ChatMessageH\x00\x42\t\n\x07payload\"U\n\x0b\x43hatMessage\x12\x0e\n\x06\x61uthor\x18\x01 \x01(\t\x12\x13\n\x0b\x61uthor_role\x18\x02 \x01(\t\x12\x0c\n\x04text\x18\x03 \x01(\t\x12\x13\n\x0bin_reply_to\x18\x04 \x01(\t\"C\n\x08Proposal\x12\r\n\x05\x61gent\x18\x01 \x01(\t\x12\x12\n\nconfidence\x18\x02 \x01(\x01\x12\x14\n\x0c\x63ontent_json\x18\x03 \x01(\t\"d\n\tConsensus\x12\x16\n\x0e\x64\x65\x63ision_maker\x18\x01 \x01(\t\x12\x10\n\x08strength\x18\x02 \x01(\x01\x12\x15\n\rdecision_json\x18\x03 \x01(\t\x12\x16\n\x0erequires_human\x18\x04 \x01(\x08\"\xd4\x01\n\x07Handoff\x12,\n\x05phase\x18\x01 \x01(\x0e\x32\x1d.forge.squad.v1.Handoff.Phase\x12\x12\n\nfrom_agent\x18\x02 \x01(\t\x12\x10\n\x08to_agent\x18\x03 \x01(\t\x12\x10\n\x08\x63ontract\x18\x04 \x01(\t\x12\x16\n\x0epayload_digest\x18\x05 \x01(\t\"K\n\x05Phase\x12\x15\n\x11PHASE_UNSPECIFIED\x10\x00\x12\t\n\x05START\x10\x01\x12\x07\n\x03\x41\x43K\x10\x02\x12\x0c\n\x08\x43OMPLETE\x10\x03\x12\t\n\x05\x45RROR\x10\x04\"4\n\x0eHitlEscalation\x12\x0e\n\x06reason\x18\x01 \x01(\t\x12\x12\n\nconfidence\x18\x02 \x01(\x01\"?\n\nStepResult\x12\x0f\n\x07step_id\x18\x01 \x01(\t\x12\x0f\n\x07success\x18\x02 \x01(\x08\x12\x0f\n\x07summary\x18\x03 \x01(\t\"\x0f\n\rHealthRequest\"0\n\x0eHealthResponse\x12\r\n\x05ready\x18\x01 \x01(\x08\x12\x0f\n\x07version\x18\x02 \x01(\t2\x9f\x01\n\x0cSquadService\x12\x46\n\x0b\x45xecuteTask\x12\x19.forge.squad.v1.SquadTask\x1a\x1a.forge.squad.v1.SquadEvent0\x01\x12G\n\x06Health\x12\x1d.forge.squad.v1.HealthRequest\x1a\x1e.forge.squad.v1.HealthResponseb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -34,23 +34,25 @@ _globals['_SQUADTASK']._serialized_start=32 _globals['_SQUADTASK']._serialized_end=168 _globals['_SQUADEVENT']._serialized_start=171 - _globals['_SQUADEVENT']._serialized_end=470 - _globals['_PROPOSAL']._serialized_start=472 - _globals['_PROPOSAL']._serialized_end=539 - _globals['_CONSENSUS']._serialized_start=541 - _globals['_CONSENSUS']._serialized_end=641 - _globals['_HANDOFF']._serialized_start=644 - _globals['_HANDOFF']._serialized_end=856 - _globals['_HANDOFF_PHASE']._serialized_start=781 - _globals['_HANDOFF_PHASE']._serialized_end=856 - _globals['_HITLESCALATION']._serialized_start=858 - _globals['_HITLESCALATION']._serialized_end=910 - _globals['_STEPRESULT']._serialized_start=912 - _globals['_STEPRESULT']._serialized_end=975 - _globals['_HEALTHREQUEST']._serialized_start=977 - _globals['_HEALTHREQUEST']._serialized_end=992 - _globals['_HEALTHRESPONSE']._serialized_start=994 - _globals['_HEALTHRESPONSE']._serialized_end=1042 - _globals['_SQUADSERVICE']._serialized_start=1045 - _globals['_SQUADSERVICE']._serialized_end=1204 + _globals['_SQUADEVENT']._serialized_end=515 + _globals['_CHATMESSAGE']._serialized_start=517 + _globals['_CHATMESSAGE']._serialized_end=602 + _globals['_PROPOSAL']._serialized_start=604 + _globals['_PROPOSAL']._serialized_end=671 + _globals['_CONSENSUS']._serialized_start=673 + _globals['_CONSENSUS']._serialized_end=773 + _globals['_HANDOFF']._serialized_start=776 + _globals['_HANDOFF']._serialized_end=988 + _globals['_HANDOFF_PHASE']._serialized_start=913 + _globals['_HANDOFF_PHASE']._serialized_end=988 + _globals['_HITLESCALATION']._serialized_start=990 + _globals['_HITLESCALATION']._serialized_end=1042 + _globals['_STEPRESULT']._serialized_start=1044 + _globals['_STEPRESULT']._serialized_end=1107 + _globals['_HEALTHREQUEST']._serialized_start=1109 + _globals['_HEALTHREQUEST']._serialized_end=1124 + _globals['_HEALTHRESPONSE']._serialized_start=1126 + _globals['_HEALTHRESPONSE']._serialized_end=1174 + _globals['_SQUADSERVICE']._serialized_start=1177 + _globals['_SQUADSERVICE']._serialized_end=1336 # @@protoc_insertion_point(module_scope) diff --git a/python/packages/forge-proto-py/src/forge_proto/squad_pb2.pyi b/python/packages/forge-proto-py/src/forge_proto/squad_pb2.pyi index 37de7da..a9b19f6 100644 --- a/python/packages/forge-proto-py/src/forge_proto/squad_pb2.pyi +++ b/python/packages/forge-proto-py/src/forge_proto/squad_pb2.pyi @@ -21,7 +21,7 @@ class SquadTask(_message.Message): def __init__(self, task_id: _Optional[str] = ..., description: _Optional[str] = ..., decision_type: _Optional[str] = ..., max_autonomy_level: _Optional[int] = ..., verification_evidence_json: _Optional[str] = ...) -> None: ... class SquadEvent(_message.Message): - __slots__ = ("task_id", "ts", "proposal", "consensus", "handoff", "hitl", "step", "error") + __slots__ = ("task_id", "ts", "proposal", "consensus", "handoff", "hitl", "step", "error", "chat") TASK_ID_FIELD_NUMBER: _ClassVar[int] TS_FIELD_NUMBER: _ClassVar[int] PROPOSAL_FIELD_NUMBER: _ClassVar[int] @@ -30,6 +30,7 @@ class SquadEvent(_message.Message): HITL_FIELD_NUMBER: _ClassVar[int] STEP_FIELD_NUMBER: _ClassVar[int] ERROR_FIELD_NUMBER: _ClassVar[int] + CHAT_FIELD_NUMBER: _ClassVar[int] task_id: str ts: str proposal: Proposal @@ -38,7 +39,20 @@ class SquadEvent(_message.Message): hitl: HitlEscalation step: StepResult error: str - def __init__(self, task_id: _Optional[str] = ..., ts: _Optional[str] = ..., proposal: _Optional[_Union[Proposal, _Mapping]] = ..., consensus: _Optional[_Union[Consensus, _Mapping]] = ..., handoff: _Optional[_Union[Handoff, _Mapping]] = ..., hitl: _Optional[_Union[HitlEscalation, _Mapping]] = ..., step: _Optional[_Union[StepResult, _Mapping]] = ..., error: _Optional[str] = ...) -> None: ... + chat: ChatMessage + def __init__(self, task_id: _Optional[str] = ..., ts: _Optional[str] = ..., proposal: _Optional[_Union[Proposal, _Mapping]] = ..., consensus: _Optional[_Union[Consensus, _Mapping]] = ..., handoff: _Optional[_Union[Handoff, _Mapping]] = ..., hitl: _Optional[_Union[HitlEscalation, _Mapping]] = ..., step: _Optional[_Union[StepResult, _Mapping]] = ..., error: _Optional[str] = ..., chat: _Optional[_Union[ChatMessage, _Mapping]] = ...) -> None: ... + +class ChatMessage(_message.Message): + __slots__ = ("author", "author_role", "text", "in_reply_to") + AUTHOR_FIELD_NUMBER: _ClassVar[int] + AUTHOR_ROLE_FIELD_NUMBER: _ClassVar[int] + TEXT_FIELD_NUMBER: _ClassVar[int] + IN_REPLY_TO_FIELD_NUMBER: _ClassVar[int] + author: str + author_role: str + text: str + in_reply_to: str + def __init__(self, author: _Optional[str] = ..., author_role: _Optional[str] = ..., text: _Optional[str] = ..., in_reply_to: _Optional[str] = ...) -> None: ... class Proposal(_message.Message): __slots__ = ("agent", "confidence", "content_json") diff --git a/python/packages/forge-squad/src/forge_squad/orchestrator.py b/python/packages/forge-squad/src/forge_squad/orchestrator.py index 81a1c7f..c53ce4f 100644 --- a/python/packages/forge-squad/src/forge_squad/orchestrator.py +++ b/python/packages/forge-squad/src/forge_squad/orchestrator.py @@ -53,6 +53,30 @@ def _consensus_dict(consensus: Any) -> dict[str, Any]: return {**consensus.model_dump(), "requires_human": consensus.requires_human} +# Nomes humanos dos papéis para a conversa (Fase 1). Papéis por domínio +# (Pauteiro/Redator/...) chegam com a galeria de personas (`persona.v1`, Fase 2). +_AGENT_DISPLAY = { + "architect": "Arquiteto", + "developer": "Desenvolvedor", + "auditor": "Auditor", + "designer": "Designer", + "ops": "Ops", +} + + +def _summarize_proposal_content(content: Any, confidence: float) -> str: + """Resumo humano da proposta para o chat — derivado do conteúdo REAL do + agente (nada fabricado); cai num texto genérico se o formato for + inesperado. `confidence` entra como sinal de convicção.""" + pct = f"{round(confidence * 100)}%" + if isinstance(content, dict): + for key in ("recommendation", "final_output", "strategy", "pattern", "notes"): + value = content.get(key) + if isinstance(value, str) and value.strip(): + return f"{value.strip()} (confiança {pct})" + return f"Analisei e registrei minha proposta (confiança {pct})." + + class UnifiedOrchestrator: """Ponte de alto nível entre todos os subsistemas do squad.""" @@ -119,6 +143,21 @@ async def execute_complex_task( } ) + # Narra o consenso na conversa (Fase 1) — o squad "fala" com o membro humano. + _pct = round(consensus.consensus_strength * 100) + if consensus.requires_human: + await self._emit_chat( + "Squad", + "SYSTEM", + f"Consenso ficou fraco ({_pct}%). Preciso da sua orientação para seguir.", + ) + else: + await self._emit_chat( + "Squad", + "SYSTEM", + f"Consenso alcançado ({_pct}%), liderado por {_AGENT_DISPLAY.get(consensus.decision_maker, consensus.decision_maker)}.", + ) + # ADR 0004: usa a property centralizada, não o número mágico 0.7. if consensus.requires_human: await self._emit( @@ -227,6 +266,16 @@ async def _emit_proposal(self, agent: str, proposal: Proposal) -> None: await self._emit( {"kind": "proposal", "agent": agent, "confidence": proposal.confidence, "content": proposal.content} ) + # Dá VOZ ao agente na conversa (Fase 1: usuário como membro da squad). + # O texto é derivado do conteúdo real da proposta — nada fabricado. + await self._emit_chat( + _AGENT_DISPLAY.get(agent, agent.capitalize()), + "AGENT", + _summarize_proposal_content(proposal.content, proposal.confidence), + ) + + async def _emit_chat(self, author: str, author_role: str, text: str) -> None: + await self._emit({"kind": "chat", "author": author, "author_role": author_role, "text": text}) async def _execute_plan_steps(self, plan: dict[str, Any], task: dict[str, Any]) -> list[dict[str, Any]]: results: list[dict[str, Any]] = [] diff --git a/python/packages/forge-squad/src/forge_squad/server.py b/python/packages/forge-squad/src/forge_squad/server.py index 15f2c11..948eb9f 100644 --- a/python/packages/forge-squad/src/forge_squad/server.py +++ b/python/packages/forge-squad/src/forge_squad/server.py @@ -86,6 +86,15 @@ def _to_squad_event(task_id: str, event: dict[str, Any]) -> squad_pb2.SquadEvent step_id=event["step_id"], success=bool(event["success"]), summary=event["summary"] ) ) + elif kind == "chat": + ev.chat.CopyFrom( + squad_pb2.ChatMessage( + author=event["author"], + author_role=event["author_role"], + text=event["text"], + in_reply_to=event.get("in_reply_to", ""), + ) + ) elif kind == "error": ev.error = event["message"] else: # pragma: no cover - guarda defensiva diff --git a/python/packages/forge-squad/tests/test_orchestrator.py b/python/packages/forge-squad/tests/test_orchestrator.py index d692823..ea12603 100644 --- a/python/packages/forge-squad/tests/test_orchestrator.py +++ b/python/packages/forge-squad/tests/test_orchestrator.py @@ -124,11 +124,18 @@ async def sink(event: dict) -> None: asyncio.run(orch.execute_complex_task({"description": "tarefa"}, event_sink=sink)) kinds = [e["kind"] for e in events] - # 3 propostas antes do consenso; consenso antes dos handoffs/steps. - assert kinds[:3] == ["proposal", "proposal", "proposal"] - assert "consensus" in kinds - assert kinds.index("consensus") < kinds.index("handoff") - assert kinds.index("handoff") < kinds.index("step") + # Fase 1: a squad "fala" (chat) intercalado com o pipeline. A espinha + # estrutural (propostas → consenso → handoff → step) ignora os chats. + structural = [k for k in kinds if k != "chat"] + assert structural[:3] == ["proposal", "proposal", "proposal"] + assert "consensus" in structural + assert structural.index("consensus") < structural.index("handoff") + assert structural.index("handoff") < structural.index("step") + # Os agentes ganham voz: há eventos de chat na conversa. + assert "chat" in kinds + chat_ev = next(e for e in events if e["kind"] == "chat") + assert chat_ev["author_role"] in ("AGENT", "SYSTEM", "HUMAN") + assert chat_ev["text"] consensus_ev = next(e for e in events if e["kind"] == "consensus") assert consensus_ev["requires_human"] is False diff --git a/python/packages/forge-squad/tests/test_squad_server.py b/python/packages/forge-squad/tests/test_squad_server.py index 0146f90..30dc134 100644 --- a/python/packages/forge-squad/tests/test_squad_server.py +++ b/python/packages/forge-squad/tests/test_squad_server.py @@ -122,6 +122,18 @@ def test_stream_de_eventos_com_consenso_forte(tmp_path): assert {p.agent for p in proposals} == {"architect", "developer", "auditor"} +def test_chat_message_atravessa_o_mapeamento_proto(tmp_path): + # Fase 1: os agentes ganham voz. Os eventos "chat" viram ChatMessage no + # proto, com author/author_role/text preenchidos (não default-zero). + events, _core = asyncio.run(_run_scenario(tmp_path, 0.9, 0.2, 0.2, approved=True, permission_allow=True)) + chats = [ev.chat for ev in events if ev.WhichOneof("payload") == "chat"] + assert chats, "esperava ao menos um ChatMessage no stream" + assert all(c.text for c in chats) + assert all(c.author_role in ("AGENT", "HUMAN", "SYSTEM") for c in chats) + # há narração dos agentes (AGENT) e do próprio squad no consenso (SYSTEM). + assert {c.author_role for c in chats} >= {"AGENT", "SYSTEM"} + + def test_requires_human_true_sobrevive_ao_mapeamento_proto(tmp_path): # Consenso fraco → requires_human True. Se o mapeamento pydantic→proto # não setasse o campo à mão, o default-zero do proto3 devolveria False. diff --git a/schemas/proto/squad.proto b/schemas/proto/squad.proto index b8a3f60..285861c 100644 --- a/schemas/proto/squad.proto +++ b/schemas/proto/squad.proto @@ -35,9 +35,21 @@ message SquadEvent { HitlEscalation hitl = 6; StepResult step = 7; string error = 8; + ChatMessage chat = 9; } } +// Mensagem de conversa do squad ao vivo — renderizada como chat ao lado da +// esteira. `author_role` ("AGENT"|"HUMAN"|"SYSTEM") distingue um membro-agente +// do membro-humano; `author` é o nome de exibição (papel). Aditivo à Fase 7: +// dá voz aos agentes e ao usuário como MEMBRO da squad, não só aprovador. +message ChatMessage { + string author = 1; + string author_role = 2; + string text = 3; + string in_reply_to = 4; +} + message Proposal { string agent = 1; double confidence = 2; diff --git a/web/src/api/squad.ts b/web/src/api/squad.ts index 75f7759..c569fdb 100644 --- a/web/src/api/squad.ts +++ b/web/src/api/squad.ts @@ -9,7 +9,7 @@ * `Handoff`/`Hitl`/`Step`/`Error`), não um envelope autoral como o de * `stream.ts` (sessão). */ -import { fetchJson } from './client' +import { ApiError, fetchJson } from './client' export interface SquadProposal { agent: string @@ -47,6 +47,15 @@ export interface SquadStep { summary: string } +/** Mensagem de conversa do squad ao vivo (proto `ChatMessage`). `author_role` + * distingue o membro-agente do membro-humano. */ +export interface SquadChatMessage { + author: string + author_role: 'AGENT' | 'HUMAN' | 'SYSTEM' + text: string + in_reply_to?: string +} + export type SquadEventPayload = | { Proposal: SquadProposal } | { Consensus: SquadConsensus } @@ -54,6 +63,7 @@ export type SquadEventPayload = | { Hitl: SquadHitl } | { Step: SquadStep } | { Error: string } + | { Chat: SquadChatMessage } export interface SquadEventEnvelope { task_id: string @@ -81,6 +91,20 @@ export async function resolveHitl(taskId: string, allow: boolean): Promise }) } +/** Envia uma mensagem do usuário à squad viva — o humano como MEMBRO, não só + * aprovador. Responde `202 Accepted` sem corpo, então NÃO usamos `fetchJson` + * (que tentaria `.json()` num corpo vazio — bug corrigido na Onda 15). */ +export async function postSquadMessage(taskId: string, text: string): Promise { + const response = await fetch(`/api/squad/${encodeURIComponent(taskId)}/message`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text }), + }) + if (!response.ok) { + throw new ApiError(`falha ao enviar mensagem (${response.status})`, `http_${response.status}`) + } +} + export interface SquadEventHandlers { onEvent: (event: SquadEventEnvelope) => void onConnectionError?: () => void diff --git a/web/src/components/screens/user/Squad.tsx b/web/src/components/screens/user/Squad.tsx index 5f9a99a..04356d5 100644 --- a/web/src/components/screens/user/Squad.tsx +++ b/web/src/components/screens/user/Squad.tsx @@ -6,9 +6,11 @@ import { useAsyncAction } from '../../../hooks/useAsyncAction' import { useToast } from '../../primitives/Toast' import { connectSquadEvents, + postSquadMessage, resolveHitl, runSquad, HANDOFF_PHASE_LABELS, + type SquadChatMessage, type SquadEventEnvelope, type SquadHandoff, type SquadStep, @@ -29,6 +31,7 @@ export function Squad() { const [events, setEvents] = useState([]) const [streamEnded, setStreamEnded] = useState(false) const [resolvedHitlCount, setResolvedHitlCount] = useState(0) + const [chatDraft, setChatDraft] = useState('') const disconnectRef = useRef<(() => void) | null>(null) const run = useAsyncAction(runSquad) @@ -68,6 +71,15 @@ export function Squad() { ) const pendingHitl = resolvedHitlCount < hitlEvents.length ? hitlEvents[hitlEvents.length - 1] : null + const chat = useMemo( + () => + events.flatMap((e, i) => (e.payload && 'Chat' in e.payload ? [{ c: e.payload.Chat, i }] : [])) as { + c: SquadChatMessage + i: number + }[], + [events], + ) + const errorMessage = useMemo(() => { for (let i = events.length - 1; i >= 0; i -= 1) { const p = events[i].payload @@ -115,6 +127,20 @@ export function Squad() { } } + async function handleSendMessage() { + const text = chatDraft.trim() + if (!taskId || !text || !active) return + setChatDraft('') + try { + await postSquadMessage(taskId, text) + // A fala volta pelo próprio stream (o backend a ecoa como ChatMessage), + // então não a inserimos localmente — evita duplicar. + } catch { + setChatDraft(text) + toast.push('error', 'falha ao enviar mensagem') + } + } + return (
@@ -260,6 +286,71 @@ export function Squad() {
+ + +
+ Conversa da squad + + você é um membro — não só o aprovador + +
+
+ {chat.length === 0 && ( +

+ {taskId ? 'a squad ainda não falou…' : 'rode uma tarefa para conversar com a squad.'} +

+ )} + {chat.map(({ c, i }) => { + const human = c.author_role === 'HUMAN' + return ( +
+
+ {c.author} + {c.author_role === 'SYSTEM' ? ' · squad' : ''} +
+
{c.text}
+
+ ) + })} +
+
+ setChatDraft(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && void handleSendMessage()} + disabled={!active} + placeholder={active ? 'escreva para a squad…' : 'a squad precisa estar em execução'} + style={{ + flex: 1, + background: 'transparent', + border: '1px solid var(--line)', + borderRadius: 6, + color: 'var(--ink)', + padding: '6px 10px', + }} + /> + +
+
) }