Skip to content
Open
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
2 changes: 1 addition & 1 deletion Cargo.lock

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

2 changes: 1 addition & 1 deletion binary/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "openflows"
version = "1.1.6"
version = "1.1.7"
edition = "2021"
license = "MIT"

Expand Down
2 changes: 1 addition & 1 deletion crates/agent-client/src/fallback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ impl FallbackClient {

fn build_fireworks_chain(model_override: Option<&str>) -> Result<Self> {
let mut clients: Vec<Box<dyn LlmClient>> = Vec::new();
let model = model_override.unwrap_or("accounts/fireworks/models/llama-v3p1-8b-instruct");
let model = model_override.unwrap_or("accounts/fireworks/models/deepseek-v3p1");

info!(
model = model,
Expand Down
2 changes: 1 addition & 1 deletion crates/agent-client/src/fireworks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ impl FireworksClient {
pub fn from_env() -> Result<Self> {
let key = std::env::var("FIREWORKS_API_KEY").context("FIREWORKS_API_KEY not set")?;
let model = std::env::var("FIREWORKS_MODEL")
.unwrap_or_else(|_| "accounts/fireworks/models/llama-v3p1-8b-instruct".to_string());
.unwrap_or_else(|_| "accounts/fireworks/models/deepseek-v3p1".to_string());
let api_url =
std::env::var("FIREWORKS_API_URL").unwrap_or_else(|_| FIREWORKS_API_URL.to_string());
Ok(Self {
Expand Down
189 changes: 187 additions & 2 deletions crates/agent-client/src/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,13 +113,15 @@
let result = match self.mcp.call_tool(&name, args.clone()).await {
Ok(r) => {
let text = r.as_text();
let truncated = truncate_tool_result(&text);
info!(
agent = persona.id,
tool = name,
result = text,
original_len = text.len(),
truncated_len = truncated.len(),
"Tool execution successful"
);
text
truncated
}
Err(e) => {
warn!(agent = persona.id, tool = name, err = %e, "Tool call failed");
Expand Down Expand Up @@ -158,6 +160,189 @@

// ── Helpers ───────────────────────────────────────────────────────────────

/// Maximum characters for a tool result before truncation.
/// GitHub API responses for issues/PRs can be extremely large (each issue
/// contains ~2KB of user metadata, URLs, reactions, etc.). When the LLM
/// receives these in full, it often runs out of context budget and produces
/// unstructured reasoning instead of the required JSON decision. This limit
/// ensures the context stays manageable.
const MAX_TOOL_RESULT_CHARS: usize = 16_000;

/// Truncates tool result text that exceeds MAX_TOOL_RESULT_CHARS.
///
/// For JSON arrays (like GitHub issue lists), this summarizes each item
/// to only the essential fields. For non-JSON or small responses, returns
/// the text unchanged.
fn truncate_tool_result(text: &str) -> String {
if text.len() <= MAX_TOOL_RESULT_CHARS {
return text.to_string();
}

// Try to parse as JSON array and slim down each item
if let Ok(arr) = serde_json::from_str::<Vec<serde_json::Value>>(text) {
let slimmed: Vec<serde_json::Value> = arr
.into_iter()
.map(|item| slim_issue_object(&item))
.collect();

if let Ok(slimmed_json) = serde_json::to_string_pretty(&slimmed) {
if slimmed_json.len() <= MAX_TOOL_RESULT_CHARS {
// Add a note that the response was summarized
return format!(
"[Note: This response was truncated from {} bytes to save context. Key fields preserved.]\n\n{}",
text.len(),
slimmed_json
);
}
// If even slimmed JSON is too large, truncate to just issue numbers and titles
let minimal: Vec<serde_json::Value> = slimmed

Check warning on line 198 in crates/agent-client/src/runner.rs

View workflow job for this annotation

GitHub Actions / Format

Diff in /home/runner/work/openflows/openflows/crates/agent-client/src/runner.rs

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[logic] (confidence: 90%)

The minimal truncation path extracts "status" but not "state". For GitHub issues, the open/closed indicator is in the "state" field, not "status". The keep_keys list includes both "state" (index 2) and "status" (index 8), but the minimal path only preserves "status", which is typically null for GitHub issue data. This means heavily truncated tool results lose the issue's open/closed status — critical information for the LLM's decision-making.

Impact: When tool results are large enough to trigger heavy truncation, the LLM receives GitHub issues without knowing whether they are open or closed, which can lead to incorrect decisions (e.g., trying to work on already-closed issues or ignoring open ones).

Evidence:

let minimal: Vec<serde_json::Value> = slimmed
    .into_iter()
    .map(|item| {
        let number = item.get("number").cloned().unwrap_or(serde_json::Value::Null);
        let title = item.get("title").cloned().unwrap_or(serde_json::Value::Null);
        let status = item.get("status").cloned().unwrap_or(serde_json::Value::Null);
        serde_json::json!({
            "number": number,
            "title": title,
            "status": status,
        })
    })
    .collect();

Suggestion: Add "state" to the minimal extraction alongside or instead of "status": let state = item.get("state").cloned().unwrap_or(serde_json::Value::Null); and include it in the output JSON.

.into_iter()
.map(|item| {
let number = item.get("number").cloned().unwrap_or(serde_json::Value::Null);
let title = item.get("title").cloned().unwrap_or(serde_json::Value::Null);
let status = item.get("status").cloned().unwrap_or(serde_json::Value::Null);
serde_json::json!({
"number": number,
"title": title,
"status": status,
})
})
.collect();

return format!(
"[Note: This response was heavily truncated from {} bytes. Only number, title, and status preserved.]\n\n{}",
text.len(),
serde_json::to_string_pretty(&minimal).unwrap_or_else(|_| format!("{:?}", minimal))
);
}
}

// For non-JSON responses, just truncate with a note.
// Use char-boundary-safe truncation to avoid panicking on multi-byte UTF-8.
let truncated: String = text.chars().take(MAX_TOOL_RESULT_CHARS).collect();
format!(
"[Note: Response truncated from {} bytes to {} bytes.]\n\n{}",
text.len(),
truncated.len(),
truncated
)
}

/// Reduce a GitHub issue/PR JSON object to its essential fields,
/// stripping verbose metadata like user URLs, reactions, timestamps, etc.
fn slim_issue_object(item: &serde_json::Value) -> serde_json::Value {
let obj = match item.as_object() {
Some(o) => o,
None => return item.clone(),
};

// Essential fields to keep for GitHub issues
let keep_keys = [
"number",
"title",
"state",
"body",
"html_url",
"labels",
"assignees",
"milestone",
"status",
"ticket_id",
"worker_id",
"priority",
"branch",
"attempts",
"outcome",
"action",
"notes",
];

let mut slim = serde_json::Map::new();
for key in &keep_keys {
if let Some(val) = obj.get(*key) {
// Further slim down nested objects
let slimmed_val = match key {

Check failure on line 264 in crates/agent-client/src/runner.rs

View workflow job for this annotation

GitHub Actions / Clippy

you don't need to add `&` to all patterns
&"labels" => slim_labels(val),
&"assignees" => slim_assignees(val),
&"milestone" => slim_milestone(val),
&"body" => {
// Truncate body text to reasonable length.
// Use chars() to avoid panicking on multi-byte UTF-8.
if let Some(s) = val.as_str() {
if s.len() > 2000 {
let truncated_body: String = s.chars().take(2000).collect();
serde_json::Value::String(format!(
"{}\n\n[...truncated from {} chars]",
truncated_body,
s.chars().count()
))
} else {
val.clone()
}
} else {
val.clone()
}
}
_ => val.clone(),
};
slim.insert(key.to_string(), slimmed_val);
}
}

serde_json::Value::Object(slim)
}

fn slim_labels(val: &serde_json::Value) -> serde_json::Value {
let arr = match val.as_array() {
Some(a) => a,
None => return val.clone(),
};

let slimmed: Vec<serde_json::Value> = arr
.iter()
.filter_map(|label| {
let obj = label.as_object()?;
Some(serde_json::json!({
"name": obj.get("name"),
"color": obj.get("color"),
}))
})
.collect();

serde_json::Value::Array(slimmed)
}

fn slim_assignees(val: &serde_json::Value) -> serde_json::Value {
let arr = match val.as_array() {
Some(a) => a,
None => return val.clone(),
};

let slimmed: Vec<serde_json::Value> = arr
.iter()
.filter_map(|user| {
let obj = user.as_object()?;
Some(serde_json::json!({
"login": obj.get("login"),
}))
})
.collect();

serde_json::Value::Array(slimmed)
}

fn slim_milestone(val: &serde_json::Value) -> serde_json::Value {
let obj = match val.as_object() {
Some(o) => o,
None => return val.clone(),
};

serde_json::json!({
"title": obj.get("title"),
"number": obj.get("number"),
})
}

/// Extracts `{"action": ..., "notes": ...}` from the agent's final text.
/// The LLM may include reasoning before the JSON object, so we scan for it.
fn extract_decision(text: &str) -> Result<AgentDecision> {
Expand Down
89 changes: 87 additions & 2 deletions crates/agent-forge/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,46 @@
};
use pocketflow_core::{Action, BatchNode, SharedStore};
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, USER_AGENT};
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::path::PathBuf;
use tracing::{info, warn};

/// Custom deserializer that converts an empty string to `None` for `Option<u64>`.
/// LLMs frequently write `"pr_number": ""` instead of `"pr_number": null` or
/// `"pr_number": 42`, which causes serde to fail. This deserializer gracefully
/// handles that case, and also converts numeric strings like `"42"`.
fn deserialize_opt_u64_from_maybe_string<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
where
D: Deserializer<'de>,
{
use serde::de;

#[derive(Deserialize)]
#[serde(untagged)]
enum MaybeU64 {
Number(u64),
Null,
String(String),
}

match MaybeU64::deserialize(deserializer) {
Ok(MaybeU64::Number(n)) => Ok(Some(n)),
Ok(MaybeU64::Null) => Ok(None),
Ok(MaybeU64::String(s)) => {
if s.is_empty() {
Ok(None)
} else {
s.parse::<u64>()
.map(Some)
.map_err(|_| de::Error::custom(format!("cannot parse \"{}\" as u64", s)))
}
}
Err(e) => Err(e),
}
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ForgeStatus {
/// Outcome status - can be "outcome" or "status" in STATUS.json
Expand All @@ -32,10 +66,14 @@
#[serde(default)]
pub branch: Option<String>,
/// PR URL if a PR was opened
#[serde(alias = "pr")]

Check warning on line 69 in crates/agent-forge/src/lib.rs

View workflow job for this annotation

GitHub Actions / Format

Diff in /home/runner/work/openflows/openflows/crates/agent-forge/src/lib.rs
pub pr_url: Option<String>,
/// PR number if a PR was opened
pub pr_number: Option<u32>,
#[serde(
deserialize_with = "deserialize_opt_u64_from_maybe_string",
default
)]
pub pr_number: Option<u64>,
/// Notes about the work done
pub notes: Option<String>,
/// Summary of changes (optional)
Expand Down Expand Up @@ -863,6 +901,12 @@
// Check for commits beyond the default branch.
// First try the local branch name, then fall back to the remote-tracking
// ref (origin/{default_branch}) which is more reliable after fetches.
// Before checking, fetch the default branch to ensure the remote ref is up-to-date.
let _ = StdCommand::new("git")
.args(["fetch", "origin", default_branch])
.current_dir(&worktree_path)
.output();

let has_commits = Self::has_commits_beyond_branch(&worktree_path, default_branch);

if !has_commits {
Expand Down Expand Up @@ -1401,6 +1445,47 @@
}
}

// If the default branch doesn't exist at all (e.g., new repo with only
// feature branches), check if HEAD has any commits. Any commits at all
// means the branch has work worth pushing, since there's no base to
// compare against. This handles the case where a repo was created with
// only a forge-1/T-CI-001 branch and no main/master.
let branch_exists_locally = local_output
.as_ref()
.map(|o| o.status.success())
.unwrap_or(false);
let branch_exists_remotely = origin_output
.as_ref()
.map(|o| o.status.success())
.unwrap_or(false);

if !branch_exists_locally && !branch_exists_remotely {
// The default branch ref doesn't exist at all.
// If HEAD has any commits, they are inherently "beyond" a non-existent base.
let head_commits = StdCommand::new("git")
.args(["rev-list", "--count", "HEAD"])
.current_dir(worktree_path)
.output();

if let Ok(o) = head_commits {
if o.status.success() {
if let Ok(count_str) = String::from_utf8(o.stdout) {
if let Ok(count) = count_str.trim().parse::<u32>() {
if count > 0 {
tracing::info!(
default_branch = %default_branch,
count,
"Default branch doesn't exist; HEAD has {} commits, treating as pushable",
count
);
return true;
}
}
}
}
}
}

false
}

Expand Down
2 changes: 1 addition & 1 deletion crates/agentflow-tui/src/setup/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ pub fn write_registry_file(config: &SetupConfig, project_dir: &std::path::Path)
"anthropic/claude-3-5-sonnet-20241022".to_string()
}
Some(p) if p.contains("Fireworks") => {
"fireworks/accounts/fireworks/models/llama-v3p1-8b-instruct".to_string()
"fireworks/accounts/fireworks/models/deepseek-v3p1".to_string()
}
_ => "openai/gpt-4o".to_string(),
});
Expand Down
Loading
Loading