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
9 changes: 5 additions & 4 deletions crates/libsy/src/algorithms/advisor_gate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -381,10 +381,11 @@ impl AdvisorGate {
review_tail: Option<&str>,
trigger: &'static str,
) -> Result<ConsultOutcome> {
// The advisor reviews the FULL transcript: system/developer content is
// normalized out of `messages` into `instructions`, so prepend it back
// as leading messages (identical {role, content} shape) — the task
// constraints the verdict must check against usually live there.
// The advisor reviews the FULL transcript. Top-level system/developer
// content lives in `instructions`, so prepend it as leading messages
// (identical {role, content} shape). The task constraints the verdict
// must check against usually live there. Inline instruction messages
// stay in place.
let transcript_messages: Vec<Message> = base
.llm_request
.instructions
Expand Down
4 changes: 2 additions & 2 deletions crates/libsy/src/algorithms/llm_class.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,8 @@ impl TaskClassifierVerdict {
/// Keeps the opening task and the last `recent_turn_window` turns after it. A
/// window of `0` keeps the task alone.
///
/// Inbound decoders normalize client system and developer content into
/// `LlmRequest::instructions`, so it never reaches this list.
/// Inline system and developer messages are kept, in their original order,
/// ahead of the task.
///
/// Selects by reference and clones only what survives — a coding-agent
/// conversation carries every tool result, so cloning it whole to keep a window
Expand Down
12 changes: 10 additions & 2 deletions crates/libsy/src/algorithms/util/affinity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,17 +63,23 @@ fn is_environment_context(block: &ContentBlock) -> bool {
.is_some_and(|(_, rest)| rest.trim().is_empty()))
}

pub(crate) fn is_environment_update(message: &Message) -> bool {
fn is_environment_update(message: &Message) -> bool {
message.role == Role::User
&& !message.content.is_empty()
&& message.content.iter().all(is_environment_context)
}

/// Inline instructions and standalone Codex environment updates sit between turns.
/// Turn detection skips them when it looks for the previous message.
pub(crate) fn is_turn_context(message: &Message) -> bool {
matches!(message.role, Role::System | Role::Developer) || is_environment_update(message)
}

pub(crate) fn has_new_user_turn(messages: &[Message]) -> bool {
messages
.iter()
.rev()
.find(|message| !is_environment_update(message))
.find(|message| !is_turn_context(message))
.is_some_and(is_user_turn)
}

Expand Down Expand Up @@ -370,6 +376,8 @@ mod tests {
has_new_user_turn(&[
Message::text(role, "follow-up"),
Message::text(Role::User, context),
Message::text(Role::System, "System guidance"),
Message::text(Role::Developer, "Developer guidance"),
]),
expected
);
Expand Down
21 changes: 21 additions & 0 deletions crates/libsy/src/algorithms/vgr/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,27 @@ fn tool_trajectory_respects_the_current_request_initiator() {
"finished={finished}, initiator={initiator:?}"
);
}
for role in [Role::System, Role::Developer] {
for (previous, expected) in [
(Message::text(Role::Assistant, "done"), true),
(call("c1"), false),
] {
let request = request(vec![
Message::text(Role::User, "fix the build"),
previous,
Message::text(role, "Follow repository instructions"),
Message::text(Role::User, "verify the change"),
]);
assert_eq!(
super::text::starts_user_turn(&request.llm_request.messages, 3),
expected
);
assert_eq!(
super::text::turn_start(&request),
if expected { 3 } else { 0 }
);
}
}
assert!(!super::text::turn_has_tool_trajectory(&Request::default()));
}

Expand Down
4 changes: 2 additions & 2 deletions crates/libsy/src/algorithms/vgr/text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use regex::Regex;
use serde_json::Value;
use switchyard_protocol::{ContentBlock, InstructionBlock, Message, Request, Role, ToolResult};

use crate::algorithms::util::affinity::{initiated_by_user, is_environment_update, is_user_turn};
use crate::algorithms::util::affinity::{initiated_by_user, is_turn_context, is_user_turn};
use crate::algorithms::util::tool_signals::classify_text;

/// One flattened turn: instructions first, then messages.
Expand Down Expand Up @@ -132,7 +132,7 @@ pub(super) fn starts_user_turn(messages: &[Message], index: usize) -> bool {
previous
.iter()
.rev()
.find(|message| !is_environment_update(message))
.find(|message| !is_turn_context(message))
}),
) else {
return false;
Expand Down
44 changes: 44 additions & 0 deletions crates/switchyard-translation/src/codecs/anthropic/buffered.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,13 @@ impl FormatCodec for AnthropicMessagesCodec {
.instructions
.iter()
.flat_map(|instruction| instruction.content.iter())
.chain(
request
.messages
.iter()
.filter(|message| matches!(message.role, Role::System | Role::Developer))
.flat_map(|message| message.content.iter()),
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
.filter_map(|block| match block {
ContentBlock::Text { text } | ContentBlock::Refusal { text } => Some(text.as_str()),
_ => None,
Expand All @@ -234,6 +241,34 @@ impl FormatCodec for AnthropicMessagesCodec {
if !system_text.is_empty() {
body.insert("system".to_string(), Value::String(system_text));
}
// Anthropic `system` is a text field, so other instruction content is dropped.
let instructions = request
.instructions
.iter()
.enumerate()
.map(|(index, block)| (block.role, &block.content, "instruction", index));
let inline = request
.messages
.iter()
.enumerate()
.filter(|(_, message)| matches!(message.role, Role::System | Role::Developer))
.map(|(index, message)| (message.role, &message.content, "message", index));
for (role, content, kind, index) in instructions.chain(inline) {
for block in content {
if !matches!(
block,
ContentBlock::Text { .. } | ContentBlock::Refusal { .. }
) {
push_lossy(
&mut diagnostics,
policy,
format!(
"Anthropic system prompt accepts only text; dropped non-text content from {role} {kind} {index}"
),
)?;
}
}
}

body.insert(
"messages".to_string(),
Expand Down Expand Up @@ -883,6 +918,11 @@ fn encode_anthropic_messages(
let mut index = 0;

while let Some(message) = messages.get(index) {
// Inline instructions are emitted in the top-level system field.
if matches!(message.role, Role::System | Role::Developer) {
index += 1;
continue;
}
if !message_is_tool_result_only(message) {
encoded.push(encode_anthropic_message(message, diagnostics, policy)?);
index += 1;
Expand All @@ -891,6 +931,10 @@ fn encode_anthropic_messages(

let mut content = Vec::new();
while let Some(tool_message) = messages.get(index) {
if matches!(tool_message.role, Role::System | Role::Developer) {
index += 1;
continue;
}
if !message_is_tool_result_only(tool_message) {
break;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -918,9 +918,11 @@ fn encode_message_without_tool_results_to_openai(
policy: &TranslationPolicy,
) -> Result<Value> {
let role = match message.role {
Role::System => "system",
Role::Developer => "developer",
Role::Assistant => "assistant",
Role::Tool => "tool",
_ => "user",
Role::User => "user",
};
let tool_calls = message
.content
Expand Down
50 changes: 25 additions & 25 deletions crates/switchyard-translation/src/codecs/responses/buffered.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ use crate::diagnostic::TranslationDiagnostic;
use crate::error::{Result, TranslationError};
use crate::format::{FormatId, WireFormat};
use crate::llm::{
AggLlmResponse, ContentBlock, FileSource, ImageSource, InstructionBlock, LlmRequest,
MediaSource, Message, OutputParams, ProviderExtensions, ReasoningParams, ResponseOutput, Role,
SamplingParams, StopReason, Tool, ToolCall, ToolChoice, ToolDefinition, ToolResult, Usage,
AggLlmResponse, ContentBlock, FileSource, ImageSource, LlmRequest, MediaSource, Message,
OutputParams, ProviderExtensions, ReasoningParams, ResponseOutput, Role, SamplingParams,
StopReason, Tool, ToolCall, ToolChoice, ToolDefinition, ToolResult, Usage,
};
use crate::policy::{DeterministicIdPolicy, TranslationPolicy};
use crate::util::{
Expand Down Expand Up @@ -97,15 +97,13 @@ impl FormatCodec for OpenAiResponsesCodec {
.and_then(Value::as_str)
.is_some_and(|id| !id.is_empty());
let mut custom_call_outputs = Vec::new();
let (messages, instructions) = decode_responses_input(
request.messages = decode_responses_input(
body.get("input").unwrap_or(&Value::String(String::new())),
stored_state,
&mut custom_call_outputs,
&mut diagnostics,
policy,
)?;
request.messages = messages;
request.instructions.extend(instructions);
let mut tool_namespaces = Map::new();
let mut custom_tools = Map::new();
request.tools =
Expand Down Expand Up @@ -457,25 +455,18 @@ impl FormatCodec for OpenAiResponsesCodec {
}
}

/// Decodes Responses `input` into ordered normalized messages and inline
/// instruction blocks.
///
/// Inline `system` and `developer` message items are returned as instruction
/// blocks rather than conversation turns. Classifying them here, before any
/// reasoning or tool-call state-machine transitions, prevents an instruction
/// item from flushing pending reasoning or disturbing tool-call grouping.
/// Decodes Responses `input` into ordered normalized messages.
fn decode_responses_input(
value: &Value,
stored_state: bool,
custom_call_outputs: &mut Vec<String>,
diagnostics: &mut Vec<TranslationDiagnostic>,
policy: &TranslationPolicy,
) -> Result<(Vec<Message>, Vec<InstructionBlock>)> {
) -> Result<Vec<Message>> {
match value {
Value::String(text) => Ok((vec![Message::text(Role::User, text)], Vec::new())),
Value::String(text) => Ok(vec![Message::text(Role::User, text)]),
Value::Array(items) => {
let mut messages = Vec::new();
let mut instructions = Vec::new();
let mut pending_tool_calls = Vec::new();
let mut pending_tool_outputs = Vec::new();
let mut deferred_messages = Vec::new();
Expand Down Expand Up @@ -526,16 +517,18 @@ fn decode_responses_input(
.to_string(),
});
}
let content = decode_responses_content(content_value);
// Inline system and developer input items are instructions, not
// conversation turns. Classify them before any state-machine
// transitions so they do not flush pending reasoning or disturb
// tool-call grouping.
if matches!(role, Role::System | Role::Developer) {
instructions.push(InstructionBlock { role, content });
let mut content = decode_responses_content(content_value);
// Inline instructions wait behind an open tool block or pending
// reasoning. They never split tool calls from their outputs, and
// reasoning keeps its place before them.
if matches!(role, Role::System | Role::Developer)
&& !(pending_tool_calls.is_empty()
&& pending_tool_outputs.is_empty()
&& pending_reasoning.is_empty())
{
deferred_messages.push(Message { role, content });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
continue;
}
let mut content = content;
// Reasoning that precedes an assistant message belongs to
// that turn; fold it in so it never surfaces as its own
// (empty-looking) assistant message downstream.
Expand All @@ -546,6 +539,10 @@ fn decode_responses_input(
let mut merged = std::mem::take(&mut pending_reasoning);
merged.append(&mut content);
content = merged;
// Instructions held behind the reasoning came before this turn.
if pending_tool_outputs.is_empty() {
messages.append(&mut deferred_messages);
}
}
if role != Role::Assistant {
flush_unattached_responses_reasoning(
Expand Down Expand Up @@ -778,7 +775,8 @@ fn decode_responses_input(
content: pending_reasoning,
});
}
Ok((messages, instructions))
messages.append(&mut deferred_messages);
Ok(messages)
}
_ => Err(TranslationError::InvalidType {
path: "$.input".to_string(),
Expand Down Expand Up @@ -837,6 +835,8 @@ fn flush_unattached_responses_reasoning(
pending_reasoning,
message,
);
// Instructions held behind the reasoning follow it.
messages.append(deferred_messages);
}

/// Flushes pending function calls and matching outputs while preserving adjacency.
Expand Down
14 changes: 13 additions & 1 deletion crates/switchyard-translation/src/compaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ pub fn normalize_compaction(request: &mut LlmRequest, headers: Option<&HeaderMap
.messages
.iter()
.rev()
.find(|message| !is_environment_update(message))
.find(|message| {
!matches!(message.role, Role::System | Role::Developer)
&& !is_environment_update(message)
})
.is_some_and(|message| message.role == Role::User && is_compaction_message(message));
}

Expand Down Expand Up @@ -115,6 +118,15 @@ mod tests {
]});
let request = decode_request(WireFormat::OpenAiChat, &body).unwrap();
assert!(request.is_compaction && request.has_compaction);
for role in [super::Role::System, super::Role::Developer] {
let mut with_instruction = request.clone();
with_instruction
.messages
.push(super::Message::text(role, "Follow repository instructions"));
with_instruction.is_compaction = false;
super::normalize_compaction(&mut with_instruction, None);
assert!(with_instruction.is_compaction);
}
body["messages"]
.as_array_mut()
.unwrap()
Expand Down
Loading
Loading