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
1 change: 1 addition & 0 deletions crates/forge-cli/src/squad.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {}
}
}
Expand Down
118 changes: 116 additions & 2 deletions crates/forge-cli/src/squad_agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -55,6 +55,11 @@ struct SquadTaskState {
/// via e2e (`run_squad_via_http_com_gate_hitl_real_e_ledger`).
tx: Option<tokio::sync::broadcast::Sender<SquadEvent>>,
pending: Option<PendingHitl>,
/// 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<String>,
}

impl SquadTaskState {
Expand All @@ -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
Expand Down Expand Up @@ -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<String> {
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
Expand Down Expand Up @@ -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<SquadAgentState>,
Path(task_id): Path<String>,
Json(body): Json<PostMessageBody>,
) -> 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`).
Expand All @@ -521,6 +604,7 @@ pub fn router(hub: SquadHub, pool: Arc<SquadPool>) -> 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 })
}

Expand Down Expand Up @@ -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));
Expand Down
46 changes: 46 additions & 0 deletions pendencias.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
42 changes: 22 additions & 20 deletions python/packages/forge-proto-py/src/forge_proto/squad_pb2.py

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

18 changes: 16 additions & 2 deletions python/packages/forge-proto-py/src/forge_proto/squad_pb2.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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
Expand All @@ -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")
Expand Down
Loading
Loading