fix: Bifrost tool-call schema, welcome dedup, tool cards, PNG diagnostics
Broad-stroke pen-adjustment landing four pieces in one commit.
1. Bifrost tool-call schema (unblocks turns + Aster + reflection)
BifrostMessage now carries optional tool_calls / tool_call_id / name
with skip_serializing_if=None so unrelated turns keep the {role,content}
wire shape. All three tool loops rewritten to push real OpenAI tool-use
shape instead of a stringified JSON blob in content: backend/local.rs
(Ani's loop + LocalSubagentRunner), server/consciousness_engine.rs
(Aster's N+1 pass), core/reflection/mod.rs (N+25). This is the root of
the "first memory tool works, then everything fails on turn 2" symptom
and the silent Aster pipeline failure — same wire bug, three loops.
2. Welcome avatar dedup + scale-up
Presence overlay was rendering on both Welcome and Dashboard, doubling
up with the centered inline portrait on Welcome. Gated to Dashboard
only. Welcome portrait now uses render_scaled at WELCOME_SCALE=2 —
visibly larger, the focal point of the landing screen.
3. PNG portrait diagnostics
Silent .ok()? failures replaced with tracing::warn! showing the actual
decode error. load_portrait_from_memfs probes the sibling
memory.git/assets/ layout in addition to memory/assets/ since
server-managed agents live under memory.git/.
4. Tool-call rendering polish
New BackendEvent::ToolCall { id, name, arguments, round } and
BackendEvent::ToolResult { id, name, output, is_error } replace the
literal "🔧 Round 1 — executing: Bash, Memory" Token text spew.
ChatMessage::Tool variant + ToolResultBlock carry card state.
render_tool_card draws bordered cards: status glyph (⟳/✓/⚠), header
with name and round, compact JSON arg summary, output preview through
markdown.rs (12 lines + "… (N more)" tail). Errors paint red.
Build clean. 109/109 tests pass.
Docs: archive prior handoffs to docs/archive/handoffs/, refresh
HANDOFF_NEXT.md, add four new task files (tui-message-interactions,
agent-profile-and-repo-manager, per-agent-seed-and-instances,
code-mode-tool-rendering) tracking the next-instance work.
This commit is contained in:
parent
fb520ebcaf
commit
c51b53718d
13 changed files with 483 additions and 174 deletions
|
|
@ -209,10 +209,7 @@ async fn handle_conversation_stream(
|
|||
crate::core::session::MessageRole::Tool => "tool",
|
||||
};
|
||||
|
||||
crate::bridge::bifrost::Message {
|
||||
role: role.to_string(),
|
||||
content,
|
||||
}
|
||||
crate::bridge::bifrost::Message::text(role, content)
|
||||
}).collect();
|
||||
drop(session);
|
||||
|
||||
|
|
|
|||
|
|
@ -150,10 +150,7 @@ impl SubagentRunner for LocalSubagentRunner {
|
|||
);
|
||||
|
||||
// Initial messages: system prompt + user prompt
|
||||
let mut messages = vec![BifrostMessage {
|
||||
role: "system".to_string(),
|
||||
content: system_prompt,
|
||||
}];
|
||||
let mut messages = vec![BifrostMessage::text("system", system_prompt)];
|
||||
|
||||
let mut final_content = String::new();
|
||||
let mut tool_round = 0u32;
|
||||
|
|
@ -170,29 +167,29 @@ impl SubagentRunner for LocalSubagentRunner {
|
|||
let progress = tool_round as f32 / max_tool_rounds as f32;
|
||||
if !warned_1 && progress >= warning_1_threshold {
|
||||
warned_1 = true;
|
||||
messages.push(BifrostMessage {
|
||||
role: "system".to_string(),
|
||||
content: format!(
|
||||
messages.push(BifrostMessage::text(
|
||||
"system",
|
||||
format!(
|
||||
"[subagent awareness] I've used {} of {} tool rounds. \
|
||||
My attention is narrowing — I may want to consolidate \
|
||||
my findings and return soon.",
|
||||
tool_round, max_tool_rounds
|
||||
),
|
||||
});
|
||||
));
|
||||
}
|
||||
|
||||
// Warning 2: nearing the limit, this is the last stretch
|
||||
if !warned_2 && progress >= warning_2_threshold {
|
||||
warned_2 = true;
|
||||
messages.push(BifrostMessage {
|
||||
role: "system".to_string(),
|
||||
content: format!(
|
||||
messages.push(BifrostMessage::text(
|
||||
"system",
|
||||
format!(
|
||||
"[subagent awareness] I'm at {} of {} tool rounds. \
|
||||
This is my last chance to produce a final answer \
|
||||
before my fork returns what I have.",
|
||||
tool_round, max_tool_rounds
|
||||
),
|
||||
});
|
||||
));
|
||||
}
|
||||
|
||||
let req = ChatCompletionRequest {
|
||||
|
|
@ -217,18 +214,22 @@ impl SubagentRunner for LocalSubagentRunner {
|
|||
|
||||
tool_round += 1;
|
||||
|
||||
// Add assistant tool-call message
|
||||
let call_text = serde_json::json!({
|
||||
"tool_calls": response.tool_calls.iter().map(|tc| {
|
||||
serde_json::json!({"id": tc.id, "name": tc.name, "arguments": tc.arguments})
|
||||
}).collect::<Vec<_>>()
|
||||
}).to_string();
|
||||
messages.push(BifrostMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: call_text,
|
||||
});
|
||||
// Add assistant tool-call message (OpenAI tool-use schema, not stringified blob)
|
||||
let calls: Vec<crate::bridge::bifrost::MessageToolCall> = response
|
||||
.tool_calls
|
||||
.iter()
|
||||
.map(|tc| crate::bridge::bifrost::MessageToolCall::function(
|
||||
tc.id.clone(),
|
||||
tc.name.clone(),
|
||||
tc.arguments.to_string(),
|
||||
))
|
||||
.collect();
|
||||
messages.push(BifrostMessage::assistant_tool_calls(
|
||||
response.content.clone(),
|
||||
calls,
|
||||
));
|
||||
|
||||
// Execute tools with context
|
||||
// Execute tools with context, bind each result by tool_call_id
|
||||
for tc in &response.tool_calls {
|
||||
let input_str = tc.arguments.to_string();
|
||||
let result =
|
||||
|
|
@ -241,10 +242,7 @@ impl SubagentRunner for LocalSubagentRunner {
|
|||
result.output
|
||||
};
|
||||
|
||||
messages.push(BifrostMessage {
|
||||
role: "tool".to_string(),
|
||||
content: output,
|
||||
});
|
||||
messages.push(BifrostMessage::tool_result(&tc.id, &tc.name, output));
|
||||
}
|
||||
|
||||
// Brief pause between tool rounds to let rate limits cool
|
||||
|
|
@ -651,10 +649,7 @@ async fn run_turn(
|
|||
MessageRole::Assistant => "assistant",
|
||||
MessageRole::Tool => "tool",
|
||||
};
|
||||
BifrostMessage {
|
||||
role: role.to_string(),
|
||||
content,
|
||||
}
|
||||
BifrostMessage::text(role, content)
|
||||
})
|
||||
.collect();
|
||||
(session.agent_id.clone(), messages)
|
||||
|
|
@ -772,29 +767,25 @@ async fn run_turn(
|
|||
}
|
||||
|
||||
tool_round += 1;
|
||||
// Tool execution events are emitted per-call below as
|
||||
// BackendEvent::ToolCall { … } so the TUI can render proper cards
|
||||
// instead of a literal "🔧 Round N — executing: Bash, Memory" text line.
|
||||
|
||||
// Tell the UI we're executing tools
|
||||
let tool_names: Vec<&str> =
|
||||
response.tool_calls.iter().map(|tc| tc.name.as_str()).collect();
|
||||
let announce = format!(
|
||||
"\n🔧 Round {} — executing: {}\n",
|
||||
tool_round,
|
||||
tool_names.join(", ")
|
||||
);
|
||||
let _ = tx
|
||||
.send(Ok(BackendEvent::Token(announce)))
|
||||
.await;
|
||||
|
||||
// Add the assistant's tool-call message to the Bifrost conversation
|
||||
let call_text = serde_json::json!({
|
||||
"tool_calls": response.tool_calls.iter().map(|tc| {
|
||||
serde_json::json!({"id": tc.id, "name": tc.name, "arguments": tc.arguments})
|
||||
}).collect::<Vec<_>>()
|
||||
}).to_string();
|
||||
messages.push(BifrostMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: call_text,
|
||||
});
|
||||
// Add the assistant's tool-call message in proper OpenAI tool-use schema
|
||||
// (not a stringified JSON blob in content — that's what broke turn 2).
|
||||
let calls: Vec<crate::bridge::bifrost::MessageToolCall> = response
|
||||
.tool_calls
|
||||
.iter()
|
||||
.map(|tc| crate::bridge::bifrost::MessageToolCall::function(
|
||||
tc.id.clone(),
|
||||
tc.name.clone(),
|
||||
tc.arguments.to_string(),
|
||||
))
|
||||
.collect();
|
||||
messages.push(BifrostMessage::assistant_tool_calls(
|
||||
response.content.clone(),
|
||||
calls,
|
||||
));
|
||||
|
||||
// Execute each tool and stream results back — now with per-agent context
|
||||
for tc in &response.tool_calls {
|
||||
|
|
@ -809,17 +800,27 @@ async fn run_turn(
|
|||
result.output
|
||||
};
|
||||
|
||||
let status = if result.is_error { "❌" } else { "✅" };
|
||||
let result_line = format!("{} **{}**: {} char(s)\n", status, tc.name, output.len());
|
||||
// Emit structured ToolCall + ToolResult events for the TUI to render
|
||||
// as cards (chat.rs subscribes). The old Token-text path is kept off.
|
||||
let _ = tx
|
||||
.send(Ok(BackendEvent::Token(result_line)))
|
||||
.send(Ok(BackendEvent::ToolCall {
|
||||
id: tc.id.clone(),
|
||||
name: tc.name.clone(),
|
||||
arguments: input_str.clone(),
|
||||
round: tool_round,
|
||||
}))
|
||||
.await;
|
||||
let _ = tx
|
||||
.send(Ok(BackendEvent::ToolResult {
|
||||
id: tc.id.clone(),
|
||||
name: tc.name.clone(),
|
||||
output: output.clone(),
|
||||
is_error: result.is_error,
|
||||
}))
|
||||
.await;
|
||||
|
||||
// Add tool result to bifrost messages for next loop iteration
|
||||
messages.push(BifrostMessage {
|
||||
role: "tool".to_string(),
|
||||
content: output,
|
||||
});
|
||||
// Bind tool result to its call by id (OpenAI tool-use schema).
|
||||
messages.push(BifrostMessage::tool_result(&tc.id, &tc.name, output));
|
||||
}
|
||||
|
||||
// Brief pause between tool rounds to let rate limits cool.
|
||||
|
|
|
|||
|
|
@ -64,6 +64,20 @@ pub enum BackendEvent {
|
|||
ScheduleActive { name: String },
|
||||
/// A scheduled event completed.
|
||||
ScheduleComplete { name: String, silent: bool },
|
||||
/// Assistant invoked a tool — UI renders a card with name + args.
|
||||
ToolCall {
|
||||
id: String,
|
||||
name: String,
|
||||
arguments: String,
|
||||
round: u32,
|
||||
},
|
||||
/// Tool execution result — UI attaches it under the matching call card.
|
||||
ToolResult {
|
||||
id: String,
|
||||
name: String,
|
||||
output: String,
|
||||
is_error: bool,
|
||||
},
|
||||
/// Stream ended cleanly.
|
||||
Done,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,11 +24,97 @@ pub struct BifrostClient {
|
|||
retry_policy: RetryPolicy,
|
||||
}
|
||||
|
||||
/// A message in OpenAI chat format
|
||||
/// A message in OpenAI chat format.
|
||||
///
|
||||
/// For plain user/assistant/system turns, only `role` and `content` are set
|
||||
/// and the wire shape matches `{role, content}`. For tool-calling turns the
|
||||
/// optional fields engage:
|
||||
///
|
||||
/// - assistant calling tools: `tool_calls = Some(...)`, `content` usually `""`
|
||||
/// - tool result: `role = "tool"`, `tool_call_id = Some(id)`, `name = Some(fn)`
|
||||
///
|
||||
/// Skipping the empty optionals on the wire keeps unrelated providers happy.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Message {
|
||||
pub role: String,
|
||||
pub content: String,
|
||||
/// Tool calls emitted by the assistant (OpenAI tool-use schema).
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub tool_calls: Option<Vec<MessageToolCall>>,
|
||||
/// Links a `role: "tool"` message back to the assistant's call id.
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub tool_call_id: Option<String>,
|
||||
/// Function name for `role: "tool"` messages (some providers require it).
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
impl Message {
|
||||
/// Plain text message — system / user / assistant without tool use.
|
||||
pub fn text(role: impl Into<String>, content: impl Into<String>) -> Self {
|
||||
Self {
|
||||
role: role.into(),
|
||||
content: content.into(),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Assistant message that called tools. `content` may be empty.
|
||||
pub fn assistant_tool_calls(content: impl Into<String>, calls: Vec<MessageToolCall>) -> Self {
|
||||
Self {
|
||||
role: "assistant".to_string(),
|
||||
content: content.into(),
|
||||
tool_calls: Some(calls),
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool-result message bound to a prior assistant tool_call by id.
|
||||
pub fn tool_result(
|
||||
tool_call_id: impl Into<String>,
|
||||
name: impl Into<String>,
|
||||
content: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
role: "tool".to_string(),
|
||||
content: content.into(),
|
||||
tool_calls: None,
|
||||
tool_call_id: Some(tool_call_id.into()),
|
||||
name: Some(name.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool call emitted by the assistant — serializable in OpenAI shape:
|
||||
/// `{id, type: "function", function: {name, arguments: "<json-string>"}}`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MessageToolCall {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub tool_type: String,
|
||||
pub function: MessageToolCallFunction,
|
||||
}
|
||||
|
||||
impl MessageToolCall {
|
||||
pub fn function(id: impl Into<String>, name: impl Into<String>, arguments: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
tool_type: "function".to_string(),
|
||||
function: MessageToolCallFunction {
|
||||
name: name.into(),
|
||||
arguments: arguments.into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MessageToolCallFunction {
|
||||
pub name: String,
|
||||
pub arguments: String,
|
||||
}
|
||||
|
||||
/// A tool definition in OpenAI format
|
||||
|
|
@ -511,7 +597,7 @@ mod tests {
|
|||
let req = ChatCompletionRequest {
|
||||
model: "openai/deepseek-v4-pro".to_string(),
|
||||
messages: vec![
|
||||
Message { role: "user".to_string(), content: "Hello".to_string() },
|
||||
Message::text("user", "Hello"),
|
||||
],
|
||||
stream: None,
|
||||
max_tokens: None,
|
||||
|
|
|
|||
|
|
@ -60,14 +60,8 @@ async fn bifrost_complete(
|
|||
let request = ChatCompletionRequest {
|
||||
model: model.to_string(),
|
||||
messages: vec![
|
||||
Message {
|
||||
role: "system".to_string(),
|
||||
content: system.to_string(),
|
||||
},
|
||||
Message {
|
||||
role: "user".to_string(),
|
||||
content: prompt.to_string(),
|
||||
},
|
||||
Message::text("system", system.to_string()),
|
||||
Message::text("user", prompt.to_string()),
|
||||
],
|
||||
temperature: Some(0.3),
|
||||
max_tokens: Some(max_tokens),
|
||||
|
|
|
|||
|
|
@ -173,14 +173,8 @@ impl ReflectionEngine {
|
|||
);
|
||||
|
||||
let mut chat_messages = vec![
|
||||
Message {
|
||||
role: "system".to_string(),
|
||||
content: system_prompt,
|
||||
},
|
||||
Message {
|
||||
role: "user".to_string(),
|
||||
content: user_content,
|
||||
},
|
||||
Message::text("system", system_prompt),
|
||||
Message::text("user", user_content),
|
||||
];
|
||||
|
||||
for _round in 0..REFLECTION_MAX_TOOL_ROUNDS {
|
||||
|
|
@ -227,16 +221,19 @@ impl ReflectionEngine {
|
|||
});
|
||||
}
|
||||
|
||||
let call_text = serde_json::json!({
|
||||
"tool_calls": response.tool_calls.iter().map(|tc| {
|
||||
serde_json::json!({"id": tc.id, "name": tc.name, "arguments": tc.arguments})
|
||||
}).collect::<Vec<_>>()
|
||||
})
|
||||
.to_string();
|
||||
chat_messages.push(Message {
|
||||
role: "assistant".to_string(),
|
||||
content: call_text,
|
||||
});
|
||||
let calls: Vec<crate::bridge::bifrost::MessageToolCall> = response
|
||||
.tool_calls
|
||||
.iter()
|
||||
.map(|tc| crate::bridge::bifrost::MessageToolCall::function(
|
||||
tc.id.clone(),
|
||||
tc.name.clone(),
|
||||
tc.arguments.to_string(),
|
||||
))
|
||||
.collect();
|
||||
chat_messages.push(Message::assistant_tool_calls(
|
||||
response.content.clone(),
|
||||
calls,
|
||||
));
|
||||
|
||||
for tc in &response.tool_calls {
|
||||
let input_str = tc.arguments.to_string();
|
||||
|
|
@ -249,10 +246,7 @@ impl ReflectionEngine {
|
|||
} else {
|
||||
result.output
|
||||
};
|
||||
chat_messages.push(Message {
|
||||
role: "tool".to_string(),
|
||||
content: output,
|
||||
});
|
||||
chat_messages.push(Message::tool_result(&tc.id, &tc.name, output));
|
||||
}
|
||||
|
||||
let delay_ms = self
|
||||
|
|
|
|||
|
|
@ -178,45 +178,57 @@ impl Session {
|
|||
Ok(Some(session))
|
||||
}
|
||||
|
||||
/// Convert to Bifrost API message format (list of {role, content} maps)
|
||||
/// Convert to Bifrost API message format (list of {role, content} maps).
|
||||
///
|
||||
/// NOTE: This produces text-only history. Real tool-call rounds are
|
||||
/// handled inline inside the per-turn loop in `backend::local::run_turn`
|
||||
/// (which builds proper assistant/tool messages with tool_call_id
|
||||
/// linkage). This helper is for cross-turn context, where the model
|
||||
/// only sees the assistant's final text — not the in-flight tool calls.
|
||||
pub fn to_bifrost_messages(&self) -> Vec<crate::bridge::bifrost::Message> {
|
||||
let mut messages = Vec::new();
|
||||
for msg in &self.messages {
|
||||
for block in &msg.blocks {
|
||||
match block {
|
||||
ContentBlock::Text { text } => {
|
||||
messages.push(crate::bridge::bifrost::Message {
|
||||
role: match msg.role {
|
||||
MessageRole::System => "system".to_string(),
|
||||
MessageRole::User => "user".to_string(),
|
||||
MessageRole::Assistant => "assistant".to_string(),
|
||||
MessageRole::Tool => "tool".to_string(),
|
||||
},
|
||||
content: text.clone(),
|
||||
});
|
||||
let role = match msg.role {
|
||||
MessageRole::System => "system",
|
||||
MessageRole::User => "user",
|
||||
MessageRole::Assistant => "assistant",
|
||||
MessageRole::Tool => "tool",
|
||||
};
|
||||
messages.push(crate::bridge::bifrost::Message::text(
|
||||
role,
|
||||
text.clone(),
|
||||
));
|
||||
}
|
||||
ContentBlock::ToolUse { name, input, .. } => {
|
||||
// Tool calls are encoded as assistant messages with tool content
|
||||
messages.push(crate::bridge::bifrost::Message {
|
||||
role: "assistant".to_string(),
|
||||
content: format!("Tool use: {name}({input})"),
|
||||
});
|
||||
// Tool calls flattened into assistant prose for
|
||||
// cross-turn context — see fn-level note above.
|
||||
messages.push(crate::bridge::bifrost::Message::text(
|
||||
"assistant",
|
||||
format!("Tool use: {name}({input})"),
|
||||
));
|
||||
}
|
||||
ContentBlock::ToolResult { tool_name, output, is_error, .. } => {
|
||||
messages.push(crate::bridge::bifrost::Message {
|
||||
role: "tool".to_string(),
|
||||
content: if *is_error {
|
||||
format!("Error ({tool_name}): {output}")
|
||||
} else {
|
||||
format!("Result ({tool_name}): {output}")
|
||||
},
|
||||
});
|
||||
let body = if *is_error {
|
||||
format!("Error ({tool_name}): {output}")
|
||||
} else {
|
||||
format!("Result ({tool_name}): {output}")
|
||||
};
|
||||
// Flatten into assistant prose rather than role=tool
|
||||
// — without a tool_call_id link, role=tool is rejected
|
||||
// by many providers on the next turn.
|
||||
messages.push(crate::bridge::bifrost::Message::text(
|
||||
"assistant",
|
||||
body,
|
||||
));
|
||||
}
|
||||
ContentBlock::Reasoning { reasoning } => {
|
||||
messages.push(crate::bridge::bifrost::Message {
|
||||
role: "assistant".to_string(),
|
||||
content: format!("[Reasoning]: {reasoning}"),
|
||||
});
|
||||
messages.push(crate::bridge::bifrost::Message::text(
|
||||
"assistant",
|
||||
format!("[Reasoning]: {reasoning}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -423,14 +423,8 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
|
|||
|
||||
// ── Tool loop ─────────────────────────────────────────────────
|
||||
let mut messages = vec![
|
||||
Message {
|
||||
role: "system".to_string(),
|
||||
content: system_prompt.to_string(),
|
||||
},
|
||||
Message {
|
||||
role: "user".to_string(),
|
||||
content: user_content,
|
||||
},
|
||||
Message::text("system", system_prompt.to_string()),
|
||||
Message::text("user", user_content),
|
||||
];
|
||||
|
||||
for _round in 0..ASTER_MAX_TOOL_ROUNDS {
|
||||
|
|
@ -468,18 +462,22 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
|
|||
return Ok(parse_observations(&content));
|
||||
}
|
||||
|
||||
// Add assistant message with tool calls
|
||||
let call_text = serde_json::json!({
|
||||
"tool_calls": response.tool_calls.iter().map(|tc| {
|
||||
serde_json::json!({"id": tc.id, "name": tc.name, "arguments": tc.arguments})
|
||||
}).collect::<Vec<_>>()
|
||||
}).to_string();
|
||||
messages.push(Message {
|
||||
role: "assistant".to_string(),
|
||||
content: call_text,
|
||||
});
|
||||
// Add assistant message with tool calls (OpenAI tool-use schema)
|
||||
let calls: Vec<crate::bridge::bifrost::MessageToolCall> = response
|
||||
.tool_calls
|
||||
.iter()
|
||||
.map(|tc| crate::bridge::bifrost::MessageToolCall::function(
|
||||
tc.id.clone(),
|
||||
tc.name.clone(),
|
||||
tc.arguments.to_string(),
|
||||
))
|
||||
.collect();
|
||||
messages.push(Message::assistant_tool_calls(
|
||||
response.content.clone(),
|
||||
calls,
|
||||
));
|
||||
|
||||
// Execute each tool call
|
||||
// Execute each tool call and bind the result by tool_call_id
|
||||
for tc in &response.tool_calls {
|
||||
let input_str = tc.arguments.to_string();
|
||||
let result = crate::core::tools::execute_tool_with_context(
|
||||
|
|
@ -492,10 +490,7 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
|
|||
result.output
|
||||
};
|
||||
|
||||
messages.push(Message {
|
||||
role: "tool".to_string(),
|
||||
content: output,
|
||||
});
|
||||
messages.push(Message::tool_result(&tc.id, &tc.name, output));
|
||||
}
|
||||
|
||||
// Brief pause between Aster's tool rounds — use the adaptive delay
|
||||
|
|
|
|||
|
|
@ -45,10 +45,7 @@ impl ServerConversation {
|
|||
ContentBlock::Text { text } => Some(text.as_str()),
|
||||
_ => None,
|
||||
}).collect::<Vec<_>>().join("\n");
|
||||
BifrostMessage {
|
||||
role: role.to_string(),
|
||||
content,
|
||||
}
|
||||
BifrostMessage::text(role, content)
|
||||
}).collect();
|
||||
|
||||
// Call Bifrost
|
||||
|
|
|
|||
|
|
@ -840,9 +840,10 @@ impl App {
|
|||
_ => self.draw_placeholder(frame),
|
||||
}
|
||||
|
||||
// Presence overlay on Welcome and Dashboard only — in Chat mode,
|
||||
// the cockpit panel shows agent state in its own register (words).
|
||||
if matches!(self.current_screen, Screen::Welcome | Screen::Dashboard) {
|
||||
// Presence overlay (corner portrait card) on Dashboard only — Welcome
|
||||
// already draws its own centered, scaled portrait inline; doubling it
|
||||
// up just creates a phantom in the corner.
|
||||
if matches!(self.current_screen, Screen::Dashboard) {
|
||||
draw_presence_overlay(frame, &self.presence, area);
|
||||
}
|
||||
}
|
||||
|
|
@ -990,10 +991,16 @@ impl App {
|
|||
frame.render_widget(bg, area);
|
||||
|
||||
// Avatar card occupies its own row in the welcome stack — centered,
|
||||
// framed, modest. It IS the "your agent is loaded" indicator. The
|
||||
// menu beneath then reads as actions on that agent.
|
||||
let avatar_card_w: u16 = portrait::RENDER_W + 2;
|
||||
let avatar_card_h: u16 = portrait::RENDER_H + 3;
|
||||
// framed, larger than the corner overlay so Annie reads as the focal
|
||||
// point of the landing screen. The render path uses `render_scaled`
|
||||
// with `WELCOME_SCALE`; cell_w = scale, cell_h = max(scale/2, 1).
|
||||
const WELCOME_SCALE: u16 = 2;
|
||||
let cell_w: u16 = WELCOME_SCALE;
|
||||
let cell_h: u16 = (WELCOME_SCALE / 2).max(1);
|
||||
let portrait_w_cells: u16 = portrait::PORTRAIT_W * cell_w;
|
||||
let portrait_h_cells: u16 = (portrait::PORTRAIT_H / 2) * cell_h;
|
||||
let avatar_card_w: u16 = portrait_w_cells + 2;
|
||||
let avatar_card_h: u16 = portrait_h_cells + 3;
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
|
|
@ -1050,14 +1057,19 @@ impl App {
|
|||
let portrait_area = Rect {
|
||||
x: card_area.x + 1,
|
||||
y: card_area.y + 1,
|
||||
width: portrait::RENDER_W,
|
||||
height: portrait::RENDER_H,
|
||||
width: portrait_w_cells,
|
||||
height: portrait_h_cells,
|
||||
};
|
||||
portrait::render(frame.buffer_mut(), portrait_area, &self.presence);
|
||||
portrait::render_scaled(
|
||||
frame.buffer_mut(),
|
||||
portrait_area,
|
||||
&self.presence,
|
||||
WELCOME_SCALE,
|
||||
);
|
||||
|
||||
let name_area = Rect {
|
||||
x: card_area.x + 1,
|
||||
y: card_area.y + 1 + portrait::RENDER_H,
|
||||
y: card_area.y + 1 + portrait_h_cells,
|
||||
width: card_area.width.saturating_sub(2),
|
||||
height: 1,
|
||||
};
|
||||
|
|
|
|||
163
src/ui/chat.rs
163
src/ui/chat.rs
|
|
@ -37,6 +37,9 @@ const USER_BLUE: Color = Color::Rgb(120, 170, 240);
|
|||
const ANI_ORANGE: Color = Color::Rgb(255, 140, 66);
|
||||
const ANI_DIM: Color = Color::Rgb(180, 120, 80);
|
||||
const STATUS_GRAY: Color = Color::Rgb(140, 140, 140);
|
||||
const TOOL_CYAN: Color = Color::Rgb(120, 200, 220);
|
||||
const TOOL_DIM: Color = Color::Rgb(80, 140, 150);
|
||||
const TOOL_ERR: Color = Color::Rgb(220, 110, 110);
|
||||
const REFLECTION_LAVENDER: Color = Color::Rgb(180, 160, 220);
|
||||
const ARCHIVIST_TEAL: Color = Color::Rgb(120, 190, 180);
|
||||
const COMPACTION_AMBER: Color = Color::Rgb(240, 180, 60);
|
||||
|
|
@ -124,6 +127,24 @@ pub enum ChatMessage {
|
|||
Assistant { text: String, ts: Instant, streaming: bool },
|
||||
Surfacing { source: String, content: String, priority: String, ts: Instant },
|
||||
System { text: String, ts: Instant },
|
||||
/// Tool invocation card — name, arguments, round, plus an attached result
|
||||
/// once it streams back. `expanded` is reserved for click-to-expand (UI
|
||||
/// interactivity lands as part of message-click work).
|
||||
Tool {
|
||||
id: String,
|
||||
name: String,
|
||||
arguments: String,
|
||||
round: u32,
|
||||
result: Option<ToolResultBlock>,
|
||||
ts: Instant,
|
||||
expanded: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolResultBlock {
|
||||
pub output: String,
|
||||
pub is_error: bool,
|
||||
}
|
||||
|
||||
pub struct ChatState {
|
||||
|
|
@ -722,6 +743,48 @@ Use Tab to toggle the cockpit pane.";
|
|||
});
|
||||
}
|
||||
}
|
||||
BackendEvent::ToolCall { id, name, arguments, round } => {
|
||||
// If a streaming assistant block is open, finalize it
|
||||
// first so the card lands beneath the just-said text.
|
||||
self.finalize_streaming();
|
||||
self.cockpit_log.push(CockpitEntry {
|
||||
kind: CockpitKind::Reflection,
|
||||
text: format!("→ {} (round {})", name, round),
|
||||
});
|
||||
self.messages.push(ChatMessage::Tool {
|
||||
id,
|
||||
name,
|
||||
arguments,
|
||||
round,
|
||||
result: None,
|
||||
ts: Instant::now(),
|
||||
expanded: false,
|
||||
});
|
||||
}
|
||||
BackendEvent::ToolResult { id, name: _, output, is_error } => {
|
||||
// Attach to the matching tool card by id. If we don't
|
||||
// find one (unusual), append a System line so it's not lost.
|
||||
let mut bound = false;
|
||||
for msg in self.messages.iter_mut().rev() {
|
||||
if let ChatMessage::Tool { id: tid, result, .. } = msg {
|
||||
if tid == &id && result.is_none() {
|
||||
*result = Some(ToolResultBlock {
|
||||
output: output.clone(),
|
||||
is_error,
|
||||
});
|
||||
bound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !bound {
|
||||
let prefix = if is_error { "[tool error] " } else { "[tool] " };
|
||||
self.messages.push(ChatMessage::System {
|
||||
text: format!("{}{}", prefix, output),
|
||||
ts: Instant::now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
BackendEvent::Done => {
|
||||
self.finalize_streaming();
|
||||
self.busy = false;
|
||||
|
|
@ -939,6 +1002,17 @@ fn draw_messages(f: &mut Frame, state: &ChatState, area: Rect) {
|
|||
)));
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
ChatMessage::Tool { name, arguments, round, result, .. } => {
|
||||
lines.extend(render_tool_card(
|
||||
name,
|
||||
arguments,
|
||||
*round,
|
||||
result.as_ref(),
|
||||
max_bubble,
|
||||
area.width,
|
||||
));
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1085,6 +1159,95 @@ fn bubble_rendered(
|
|||
lines
|
||||
}
|
||||
|
||||
/// Render a tool invocation card. Header shows `▶ name (round N)`, then
|
||||
/// the arguments (truncated/wrapped), then — once a result has streamed
|
||||
/// back — a `└─ result` section rendered as markdown so output reads like
|
||||
/// code or prose, not a single jammed line. Errors paint the border red.
|
||||
fn render_tool_card(
|
||||
name: &str,
|
||||
arguments: &str,
|
||||
round: u32,
|
||||
result: Option<&ToolResultBlock>,
|
||||
max_width: usize,
|
||||
container_width: u16,
|
||||
) -> Vec<Line<'static>> {
|
||||
let is_err = result.map(|r| r.is_error).unwrap_or(false);
|
||||
let border_color = if is_err { TOOL_ERR } else { TOOL_CYAN };
|
||||
let dim_color = if is_err { TOOL_ERR } else { TOOL_DIM };
|
||||
let border = Style::default().fg(border_color);
|
||||
|
||||
// Header marker + status glyph: pending=⟳, ok=✓, err=⚠
|
||||
let glyph = match result {
|
||||
None => '⟳',
|
||||
Some(r) if r.is_error => '⚠',
|
||||
Some(_) => '✓',
|
||||
};
|
||||
let title = format!("{} {} · round {}", glyph, name, round);
|
||||
|
||||
// Body: arguments (compact one-line summary), then result if present.
|
||||
let mut body_lines: Vec<Line<'static>> = Vec::new();
|
||||
|
||||
let args_summary = summarize_tool_args(arguments);
|
||||
body_lines.push(Line::from(vec![Span::styled(
|
||||
args_summary,
|
||||
Style::default().fg(dim_color),
|
||||
)]));
|
||||
|
||||
if let Some(r) = result {
|
||||
body_lines.push(Line::from(""));
|
||||
let preview = preview_output(&r.output, 12);
|
||||
let rendered = markdown::render(&preview, if r.is_error { TOOL_ERR } else { ANI_ORANGE });
|
||||
body_lines.extend(rendered);
|
||||
if r.output.lines().count() > 12 {
|
||||
body_lines.push(Line::from(Span::styled(
|
||||
format!(" … ({} more lines)", r.output.lines().count() - 12),
|
||||
Style::default().fg(dim_color).add_modifier(Modifier::ITALIC),
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
bubble_rendered(&title, &body_lines, max_width, border, BubbleAlign::Left, container_width)
|
||||
}
|
||||
|
||||
/// Compact one-line summary of tool arguments. Keys are kept, long string
|
||||
/// values are clipped to 60 chars with an ellipsis. Falls back to the raw
|
||||
/// string if parsing fails.
|
||||
fn summarize_tool_args(arguments: &str) -> String {
|
||||
let parsed: Result<serde_json::Value, _> = serde_json::from_str(arguments);
|
||||
match parsed {
|
||||
Ok(serde_json::Value::Object(map)) => {
|
||||
let parts: Vec<String> = map
|
||||
.iter()
|
||||
.map(|(k, v)| {
|
||||
let s = match v {
|
||||
serde_json::Value::String(s) => clip(s, 60),
|
||||
other => clip(&other.to_string(), 60),
|
||||
};
|
||||
format!("{}: {}", k, s)
|
||||
})
|
||||
.collect();
|
||||
parts.join(" · ")
|
||||
}
|
||||
Ok(other) => clip(&other.to_string(), 120),
|
||||
Err(_) => clip(arguments, 120),
|
||||
}
|
||||
}
|
||||
|
||||
fn clip(s: &str, max: usize) -> String {
|
||||
if s.chars().count() <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
|
||||
out.push('…');
|
||||
out
|
||||
}
|
||||
|
||||
/// Take the first `n` lines verbatim for in-card preview.
|
||||
fn preview_output(s: &str, n: usize) -> String {
|
||||
let lines: Vec<&str> = s.lines().take(n).collect();
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn wrap_words(text: &str, width: usize) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for paragraph in text.split('\n') {
|
||||
|
|
|
|||
|
|
@ -66,9 +66,20 @@ impl PortraitSource {
|
|||
|
||||
/// Decode an image file (PNG or JPEG), resize to portrait grid dims,
|
||||
/// and produce a colored pixel array. Returns `None` on any I/O or
|
||||
/// decode error — the caller falls back to the hand-crafted Annie.
|
||||
/// decode error — but now logs the reason so we can see why a PNG
|
||||
/// didn't take instead of silently falling back to the silhouette.
|
||||
pub fn from_path(path: &Path) -> Option<Self> {
|
||||
let img = image::open(path).ok()?;
|
||||
let img = match image::open(path) {
|
||||
Ok(img) => img,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
path = %path.display(),
|
||||
error = %e,
|
||||
"portrait decode failed"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let resized = img.resize_exact(
|
||||
PORTRAIT_W as u32,
|
||||
PORTRAIT_H as u32,
|
||||
|
|
|
|||
|
|
@ -173,27 +173,60 @@ impl Presence {
|
|||
}
|
||||
}
|
||||
|
||||
/// Try to load a portrait from a path. Silently no-ops on failure.
|
||||
/// Try to load a portrait from a path. Logs a warning on failure so we
|
||||
/// can see _why_ a PNG didn't take (decode error, unsupported format,
|
||||
/// path not readable) instead of silently falling back to the silhouette.
|
||||
pub fn load_portrait<P: AsRef<Path>>(&mut self, path: P) {
|
||||
if let Some(src) = PortraitSource::from_path(path.as_ref()) {
|
||||
self.portrait_source = Some(src);
|
||||
let path = path.as_ref();
|
||||
match PortraitSource::from_path(path) {
|
||||
Some(src) => {
|
||||
tracing::info!(path = %path.display(), "portrait loaded");
|
||||
self.portrait_source = Some(src);
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
path = %path.display(),
|
||||
"portrait failed to load — leaving silhouette in place"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to load a portrait from an agent's memfs root. Looks at:
|
||||
/// `<memfs_root>/assets/portrait.png` then `assets/portrait.jpg`.
|
||||
/// `<memfs_root>/assets/portrait.{png,jpg,jpeg}`, then a sibling
|
||||
/// `<memfs_root>/../memory.git/assets/...` because agents created via
|
||||
/// the server inventory live under `memory.git/` rather than `memory/`.
|
||||
///
|
||||
/// Crucially, `assets/` is OUTSIDE `system/` — it does NOT get pinned
|
||||
/// into the agent's context window by `core::prompt::build`. See
|
||||
/// `memory/feedback_system_folder_pinned.md`.
|
||||
pub fn load_portrait_from_memfs<P: AsRef<Path>>(&mut self, memfs_root: P) {
|
||||
for stem in &["portrait.png", "portrait.jpg", "portrait.jpeg"] {
|
||||
let candidate: PathBuf = memfs_root.as_ref().join("assets").join(stem);
|
||||
let memfs_root = memfs_root.as_ref();
|
||||
let stems = ["portrait.png", "portrait.jpg", "portrait.jpeg"];
|
||||
|
||||
let mut candidates: Vec<PathBuf> = Vec::new();
|
||||
for stem in &stems {
|
||||
candidates.push(memfs_root.join("assets").join(stem));
|
||||
}
|
||||
// Server-layout fallback: agent root sibling at `memory.git/`.
|
||||
if let Some(parent) = memfs_root.parent() {
|
||||
for stem in &stems {
|
||||
candidates.push(parent.join("memory.git").join("assets").join(stem));
|
||||
candidates.push(parent.join("memory").join("assets").join(stem));
|
||||
}
|
||||
}
|
||||
|
||||
for candidate in &candidates {
|
||||
if candidate.exists() {
|
||||
self.load_portrait(&candidate);
|
||||
self.load_portrait(candidate);
|
||||
return;
|
||||
}
|
||||
}
|
||||
tracing::debug!(
|
||||
memfs_root = %memfs_root.display(),
|
||||
tried = candidates.len(),
|
||||
"no per-agent portrait found in assets/ — using silhouette"
|
||||
);
|
||||
}
|
||||
|
||||
pub fn set_position(&mut self, p: Position) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue