Skip to content

Commit 9f2a11b

Browse files
wpfleger96npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
andauthored
feat(buzz-agent): emit agent_thought_chunk for reasoning content (#1436)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
1 parent e5aa4a2 commit 9f2a11b

5 files changed

Lines changed: 354 additions & 14 deletions

File tree

crates/buzz-acp/src/acp.rs

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -144,19 +144,20 @@ pub struct AcpClient {
144144
observer_agent_index: Option<usize>,
145145
/// Best-effort context attached to raw ACP wire events.
146146
observer_context: ObserverContext,
147-
/// Goose-specific: most recently observed `_meta.goose.activeRunId` from
148-
/// a `session/update` notification of kind `session_info_update`.
147+
/// Most recently observed `_meta.goose.activeRunId` from a
148+
/// `session/update` notification of kind `session_info_update`.
149149
///
150-
/// Goose emits this whenever it starts or clears an active prompt run
150+
/// Both goose and buzz-agent emit `session_info_update` with this field;
151+
/// goose emits it whenever it starts or clears an active prompt run
151152
/// (`crates/goose/src/acp/server.rs:2277` `send_active_run_update`).
152153
/// Required as `expectedRunId` when calling the non-standard
153154
/// `_goose/unstable/session/steer` method to inject a message into an
154155
/// in-flight turn without cancelling it.
155156
///
156157
/// `None` until the first `session_info_update` arrives, or after the
157-
/// run clears (goose emits `activeRunId: null` at end of turn). Other
158-
/// agents will simply never populate this — readers must treat `None`
159-
/// as "no active run to steer into" and fall back to cancel+merge.
158+
/// run clears (goose/buzz-agent emit `activeRunId: null` at end of turn).
159+
/// Other agents may leave this unset — readers must treat `None` as
160+
/// "no active run to steer into" and fall back to cancel+merge.
160161
active_run_id: Option<String>,
161162
/// Per-turn channel for receiving goose-native non-cancelling steer
162163
/// requests from the main loop. Installed by
@@ -490,8 +491,9 @@ impl AcpClient {
490491
/// Most recently observed goose `_meta.goose.activeRunId` from a
491492
/// `session_info_update`, if any.
492493
///
493-
/// Goose-only: other agents leave this `None` for the lifetime of the
494-
/// client. Read directly by `read_until_response_with_idle_timeout`'s
494+
/// Both goose and buzz-agent emit `session_info_update`; other agents
495+
/// leave this `None` for the lifetime of the client. Read directly by
496+
/// `read_until_response_with_idle_timeout`'s
495497
/// steer arm at write time (see [`crate::pool::SteerRequest`] for
496498
/// why the read loop owns this); production callers do not need this
497499
/// accessor. Kept as `pub` so tests can introspect the field.
@@ -1266,11 +1268,11 @@ impl AcpClient {
12661268
false
12671269
}
12681270
"session_info_update" => {
1269-
// Goose-only as of writing: `_meta.goose.activeRunId` carries
1270-
// the id of the currently-active prompt run, or `null` when
1271-
// the run has cleared. Other agents don't emit this field;
1272-
// for them `active_run_id` stays `None` and steer callers
1273-
// will fall back to cancel+merge.
1271+
// Both goose and buzz-agent emit `session_info_update` with
1272+
// `_meta.goose.activeRunId`: the id of the currently-active
1273+
// prompt run, or `null` when the run has cleared. Other agents
1274+
// don't emit this field; for them `active_run_id` stays `None`
1275+
// and steer callers will fall back to cancel+merge.
12741276
//
12751277
// Per the ACP `SessionInfoUpdate` schema, `_meta` is a field
12761278
// on the update object itself — nested inside `update`, not

crates/buzz-agent/src/agent.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,20 @@ impl RunCtx<'_> {
160160
);
161161
}
162162

163+
if !response.reasoning.is_empty() {
164+
wire::send(
165+
self.wire,
166+
wire::session_update(
167+
self.session_id,
168+
json!({
169+
"sessionUpdate": "agent_thought_chunk",
170+
"content": { "type": "text", "text": &response.reasoning }
171+
}),
172+
),
173+
)
174+
.await;
175+
}
176+
163177
if !response.text.is_empty() {
164178
wire::send(
165179
self.wire,

crates/buzz-agent/src/llm.rs

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,7 @@ fn databricks_v2_path(route: DatabricksV2Route) -> &'static str {
620620

621621
fn parse_responses(v: Value) -> Result<LlmResponse, AgentError> {
622622
let mut text = String::new();
623+
let mut reasoning = String::new();
623624
let mut tool_calls = Vec::new();
624625
let mut saw_function_call = false;
625626

@@ -663,7 +664,28 @@ fn parse_responses(v: Value) -> Result<LlmResponse, AgentError> {
663664
args,
664665
)?);
665666
}
666-
// Reasoning items are opaque/internal; we don't replay them.
667+
Some("reasoning") => {
668+
// Reasoning summary items from the Responses API. Each item has a
669+
// `summary` array of `{"type": "summary_text", "text": "..."}` objects.
670+
for s in item
671+
.get("summary")
672+
.and_then(Value::as_array)
673+
.into_iter()
674+
.flatten()
675+
{
676+
if matches!(
677+
s.get("type").and_then(Value::as_str),
678+
Some("summary_text" | "text")
679+
) {
680+
if let Some(t) = s.get("text").and_then(Value::as_str) {
681+
if !reasoning.is_empty() {
682+
reasoning.push('\n');
683+
}
684+
reasoning.push_str(t);
685+
}
686+
}
687+
}
688+
}
667689
// Unknown types ignored for forward-compat.
668690
_ => {}
669691
}
@@ -691,6 +713,7 @@ fn parse_responses(v: Value) -> Result<LlmResponse, AgentError> {
691713
tool_calls,
692714
stop,
693715
input_tokens,
716+
reasoning,
694717
})
695718
}
696719

@@ -760,6 +783,7 @@ fn parse_anthropic(v: Value) -> Result<LlmResponse, AgentError> {
760783
let stop = map_stop(v.get("stop_reason").and_then(Value::as_str));
761784
let mut tool_calls = Vec::new();
762785
let mut text = String::new();
786+
let mut reasoning = String::new();
763787
if let Some(blocks) = v.get("content").and_then(Value::as_array) {
764788
for b in blocks {
765789
match b.get("type").and_then(Value::as_str) {
@@ -768,6 +792,15 @@ fn parse_anthropic(v: Value) -> Result<LlmResponse, AgentError> {
768792
text.push_str(t);
769793
}
770794
}
795+
Some("thinking") => {
796+
// Anthropic extended thinking block: `{"type": "thinking", "thinking": "..."}`
797+
if let Some(t) = b.get("thinking").and_then(Value::as_str) {
798+
if !reasoning.is_empty() {
799+
reasoning.push('\n');
800+
}
801+
reasoning.push_str(t);
802+
}
803+
}
771804
Some("tool_use") => tool_calls.push(make_tool_call(
772805
str_field(b, "id"),
773806
str_field(b, "name"),
@@ -783,6 +816,7 @@ fn parse_anthropic(v: Value) -> Result<LlmResponse, AgentError> {
783816
tool_calls,
784817
stop,
785818
input_tokens,
819+
reasoning,
786820
})
787821
}
788822

@@ -797,6 +831,18 @@ fn parse_openai(v: Value) -> Result<LlmResponse, AgentError> {
797831
.get("message")
798832
.ok_or_else(|| AgentError::Llm("missing message".into()))?;
799833
let text = str_field(msg, "content");
834+
// DeepSeek and vLLM-style OpenAI-compat hosts expose reasoning tokens on the
835+
// message object. Prefer `reasoning_content` (DeepSeek's field name); fall
836+
// back to `reasoning` (some other providers). Both are absent for standard
837+
// OpenAI responses, which leaves this empty without any special-casing.
838+
let reasoning = {
839+
let rc = str_field(msg, "reasoning_content");
840+
if rc.is_empty() {
841+
str_field(msg, "reasoning")
842+
} else {
843+
rc
844+
}
845+
};
800846
let mut tool_calls = Vec::new();
801847
if let Some(arr) = msg.get("tool_calls").and_then(Value::as_array) {
802848
for tc in arr {
@@ -819,6 +865,7 @@ fn parse_openai(v: Value) -> Result<LlmResponse, AgentError> {
819865
tool_calls,
820866
stop,
821867
input_tokens,
868+
reasoning,
822869
})
823870
}
824871

crates/buzz-agent/src/types.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,15 @@ pub struct LlmResponse {
139139
/// tokens, so reading it alone would undercount). Used to gate handoff on
140140
/// the real token budget rather than a byte estimate.
141141
pub input_tokens: Option<u64>,
142+
/// Reasoning/thinking content emitted by the model before its answer, if
143+
/// any. Non-empty when the provider returns extended-thinking tokens:
144+
///
145+
/// - Responses API: concatenated `summary[].text` from `type == "reasoning"` output items.
146+
/// - Anthropic: concatenated `thinking` from `type == "thinking"` content blocks.
147+
/// - OpenAI chat/completions: not exposed; always empty.
148+
///
149+
/// Empty string when the provider returned no reasoning content.
150+
pub reasoning: String,
142151
}
143152

144153
#[derive(Debug, Clone, Copy, PartialEq)]

0 commit comments

Comments
 (0)