Skip to content

Commit f38311a

Browse files
committed
fix: try to fix FunctionDefinition for Gemini pro's MALFORMED_FUNCTION_CALL
1 parent dad1235 commit f38311a

File tree

9 files changed

+218
-17
lines changed

9 files changed

+218
-17
lines changed

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ futures-util = "0.3"
5555
http = "1.3"
5656
mime = "0.3"
5757
serde = { version = "1", features = ["derive"] }
58-
serde_json = "1"
58+
serde_json = { version = "1" }
5959
serde_bytes = "0.11"
6060
ic_cose_types = "0.9"
6161
ic_cose = "0.9"

agents/anda_assistant/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,5 @@ dotenv = { workspace = true }
2626
clap = { workspace = true }
2727
hex = { workspace = true }
2828
ic-agent = { workspace = true }
29+
structured-logger = { workspace = true }
2930
anda_web3_client = { path = "../../anda_web3_client", version = "0.9" }

agents/anda_assistant/examples/hello_assistant.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ use candid::Principal;
1818
use clap::Parser;
1919
use ic_agent::identity::BasicIdentity;
2020
use std::{collections::BTreeSet, sync::Arc};
21+
use structured_logger::{Builder, async_json::new_writer, get_env_level};
2122
use tokio::time::sleep;
2223

2324
#[derive(Parser)]
@@ -55,6 +56,11 @@ async fn main() {
5556
dotenv::dotenv().ok();
5657
let cli = Cli::parse();
5758

59+
// Initialize structured logging with JSON format
60+
Builder::with_level(&get_env_level().to_string())
61+
.with_target_writer("*", new_writer(tokio::io::stdout()))
62+
.init();
63+
5864
let id_secret = hex::decode(&cli.id_secret).unwrap();
5965
let id_secret: [u8; 32] = id_secret
6066
.try_into()

anda_core/Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ name = "anda_core"
33
description = "Core types and traits for Anda -- an AI agent framework built with Rust, powered by ICP and TEEs."
44
repository = "https://github.com/ldclabs/anda/tree/main/anda_core"
55
publish = true
6-
version = "0.9.9"
6+
version = "0.9.10"
77
edition.workspace = true
88
keywords.workspace = true
99
categories.workspace = true
@@ -18,7 +18,7 @@ ciborium = { workspace = true }
1818
chrono = { workspace = true }
1919
futures = { workspace = true }
2020
serde = { workspace = true }
21-
serde_json = { workspace = true }
21+
serde_json = { workspace = true, features = ["preserve_order"] }
2222
serde_bytes = { workspace = true }
2323
http = { workspace = true }
2424
thiserror = { workspace = true }

anda_core/src/model.rs

Lines changed: 196 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
//! - Core AI capabilities traits ([`CompletionFeatures`], [`EmbeddingFeatures`]).
1010
1111
use candid::Principal;
12+
use serde::ser::{SerializeMap, SerializeSeq, Serializer};
1213
use serde::{Deserialize, Serialize};
1314
use serde_json::{Map, json};
1415
use std::collections::BTreeMap;
@@ -375,13 +376,113 @@ pub struct FunctionDefinition {
375376
pub description: String,
376377

377378
/// JSON schema defining the function's parameters.
379+
#[serde(serialize_with = "serialize_openapi_schema_ordered")]
378380
pub parameters: Json,
379381

380382
/// Whether to enable strict schema adherence when generating the function call. If set to true, the model will follow the exact schema defined in the parameters field. Only a subset of JSON Schema is supported when strict is true.
381383
#[serde(skip_serializing_if = "Option::is_none")]
382384
pub strict: Option<bool>,
383385
}
384386

387+
pub fn serialize_optional_openapi_schema_ordered<S>(
388+
value: &Option<Json>,
389+
serializer: S,
390+
) -> Result<S::Ok, S::Error>
391+
where
392+
S: Serializer,
393+
{
394+
match value {
395+
None => serializer.serialize_none(),
396+
Some(v) => serialize_openapi_schema_ordered(v, serializer),
397+
}
398+
}
399+
400+
pub fn serialize_openapi_schema_ordered<S>(value: &Json, serializer: S) -> Result<S::Ok, S::Error>
401+
where
402+
S: Serializer,
403+
{
404+
struct Ordered<'a>(&'a Json);
405+
406+
impl<'a> serde::Serialize for Ordered<'a> {
407+
fn serialize<S2>(&self, serializer: S2) -> Result<S2::Ok, S2::Error>
408+
where
409+
S2: Serializer,
410+
{
411+
serialize_openapi_schema_ordered(self.0, serializer)
412+
}
413+
}
414+
415+
match value {
416+
Json::Null => serializer.serialize_none(),
417+
Json::Bool(b) => serializer.serialize_bool(*b),
418+
Json::Number(n) => n.serialize(serializer),
419+
Json::String(s) => serializer.serialize_str(s),
420+
Json::Array(items) => {
421+
let mut seq = serializer.serialize_seq(Some(items.len()))?;
422+
for item in items {
423+
seq.serialize_element(&Ordered(item))?;
424+
}
425+
seq.end()
426+
}
427+
Json::Object(map) => {
428+
// Gemini preview models can be sensitive to schema key order.
429+
// Emit a deterministic, schema-friendly ordering recursively:
430+
// 1) common schema keys in a fixed order
431+
// 2) remaining keys in lexical order
432+
433+
// https://ai.google.dev/api/caching#FunctionDeclaration
434+
const FIXED_ORDER: [&str; 23] = [
435+
"name",
436+
"type",
437+
"format",
438+
"title",
439+
"description",
440+
"nullable",
441+
"enum",
442+
"maxItems",
443+
"minItems",
444+
"properties",
445+
"required",
446+
"minProperties",
447+
"maxProperties",
448+
"minLength",
449+
"maxLength",
450+
"pattern",
451+
"example",
452+
"anyOf",
453+
"propertyOrdering",
454+
"default",
455+
"items",
456+
"minimum",
457+
"maximum",
458+
];
459+
460+
let mut out = serializer.serialize_map(Some(map.len()))?;
461+
462+
for key in FIXED_ORDER {
463+
if let Some(v) = map.get(key) {
464+
out.serialize_entry(key, &Ordered(v))?;
465+
}
466+
}
467+
468+
let mut rest_keys: Vec<&str> = map
469+
.keys()
470+
.map(|k| k.as_str())
471+
.filter(|k| !FIXED_ORDER.contains(k))
472+
.collect();
473+
rest_keys.sort_unstable();
474+
475+
for key in rest_keys {
476+
if let Some(v) = map.get(key) {
477+
out.serialize_entry(key, &Ordered(v))?;
478+
}
479+
}
480+
481+
out.end()
482+
}
483+
}
484+
}
485+
385486
impl FunctionDefinition {
386487
/// Modifies the function name with a prefix.
387488
pub fn name_with_prefix(mut self, prefix: &str) -> Self {
@@ -582,15 +683,36 @@ mod tests {
582683
.into();
583684
// println!("{}", documents);
584685

585-
assert_eq!(
586-
documents.to_string(),
587-
"<documents>\n{\"content\":\"Test document 1.\",\"metadata\":{\"_id\":1}}\n{\"content\":\"Test document 2.\",\"metadata\":{\"_id\":2,\"a\":\"b\",\"key\":\"value\"}}\n</documents>"
588-
);
686+
let s = documents.to_string();
687+
let lines: Vec<&str> = s.lines().collect();
688+
assert_eq!(lines[0], "<documents>");
689+
assert_eq!(lines[3], "</documents>");
690+
691+
let doc1: Json = serde_json::from_str(lines[1]).unwrap();
692+
assert_eq!(doc1.get("content").unwrap(), "Test document 1.");
693+
assert_eq!(doc1.get("metadata").unwrap().get("_id").unwrap(), 1);
694+
695+
let doc2: Json = serde_json::from_str(lines[2]).unwrap();
696+
assert_eq!(doc2.get("content").unwrap(), "Test document 2.");
697+
assert_eq!(doc2.get("metadata").unwrap().get("_id").unwrap(), 2);
698+
assert_eq!(doc2.get("metadata").unwrap().get("key").unwrap(), "value");
699+
assert_eq!(doc2.get("metadata").unwrap().get("a").unwrap(), "b");
700+
589701
let documents = documents.with_tag("my_docs".to_string());
590-
assert_eq!(
591-
documents.to_string(),
592-
"<my_docs>\n{\"content\":\"Test document 1.\",\"metadata\":{\"_id\":1}}\n{\"content\":\"Test document 2.\",\"metadata\":{\"_id\":2,\"a\":\"b\",\"key\":\"value\"}}\n</my_docs>"
593-
);
702+
let s = documents.to_string();
703+
let lines: Vec<&str> = s.lines().collect();
704+
assert_eq!(lines[0], "<my_docs>");
705+
assert_eq!(lines[3], "</my_docs>");
706+
707+
let doc1: Json = serde_json::from_str(lines[1]).unwrap();
708+
assert_eq!(doc1.get("content").unwrap(), "Test document 1.");
709+
assert_eq!(doc1.get("metadata").unwrap().get("_id").unwrap(), 1);
710+
711+
let doc2: Json = serde_json::from_str(lines[2]).unwrap();
712+
assert_eq!(doc2.get("content").unwrap(), "Test document 2.");
713+
assert_eq!(doc2.get("metadata").unwrap().get("_id").unwrap(), 2);
714+
assert_eq!(doc2.get("metadata").unwrap().get("key").unwrap(), "value");
715+
assert_eq!(doc2.get("metadata").unwrap().get("a").unwrap(), "b");
594716
}
595717

596718
#[test]
@@ -815,4 +937,70 @@ mod tests {
815937
&serde_json::json!({"x": true})
816938
);
817939
}
940+
941+
#[test]
942+
fn test_function_definition_parameters_openapi_order() {
943+
let def = FunctionDefinition {
944+
name: "trigger_paywall".into(),
945+
description: "Trigger payment".into(),
946+
parameters: serde_json::json!({
947+
// Intentionally not in the preferred order.
948+
"properties": {
949+
"hook_text": {
950+
"description": "hook",
951+
"type": "string"
952+
},
953+
"reason": {
954+
"description": "reason",
955+
"type": "string"
956+
}
957+
},
958+
"description": "top",
959+
"required": ["reason", "hook_text"],
960+
"type": "object"
961+
}),
962+
strict: None,
963+
};
964+
965+
let s = serde_json::to_string(&def).unwrap();
966+
let start =
967+
s.find("\"parameters\":{").expect("parameters should exist") + "\"parameters\":{".len();
968+
let sub = &s[start..];
969+
let i_type = sub.find("\"type\"").unwrap();
970+
let i_props = sub.find("\"properties\"").unwrap();
971+
let i_req = sub.find("\"required\"").unwrap();
972+
let i_desc = sub.find("\"description\"").unwrap();
973+
assert!(i_type < i_props);
974+
assert!(i_props > i_desc);
975+
assert!(i_props < i_req);
976+
}
977+
978+
#[test]
979+
fn test_function_definition_nested_schema_order_is_deterministic() {
980+
let def = FunctionDefinition {
981+
name: "trigger_paywall".into(),
982+
description: "Trigger payment".into(),
983+
parameters: serde_json::json!({
984+
"type": "object",
985+
"properties": {
986+
"hook_text": {
987+
// Intentionally reverse order.
988+
"description": "hook",
989+
"type": "string"
990+
}
991+
},
992+
"required": ["hook_text"],
993+
}),
994+
strict: None,
995+
};
996+
997+
let s = serde_json::to_string(&def).unwrap();
998+
// Ensure nested property schema emits type before description.
999+
let needle = "\"hook_text\":{";
1000+
let start = s.find(needle).unwrap() + needle.len();
1001+
let sub = &s[start..];
1002+
let i_type = sub.find("\"type\"").unwrap();
1003+
let i_desc = sub.find("\"description\"").unwrap();
1004+
assert!(i_type < i_desc);
1005+
}
8181006
}

anda_engine/Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ name = "anda_engine"
33
description = "Agents engine for Anda -- an AI agent framework built with Rust, powered by ICP and TEEs."
44
repository = "https://github.com/ldclabs/anda/tree/main/anda_engine"
55
publish = true
6-
version = "0.9.14"
6+
version = "0.9.15"
77
edition.workspace = true
88
keywords.workspace = true
99
categories.workspace = true
@@ -28,7 +28,7 @@ futures-util = { workspace = true }
2828
mime = { workspace = true }
2929
serde = { workspace = true }
3030
serde_bytes = { workspace = true }
31-
serde_json = { workspace = true }
31+
serde_json = { workspace = true, features = ["preserve_order"] }
3232
http = { workspace = true }
3333
object_store = { workspace = true }
3434
ic_auth_types = { workspace = true, features = ["xid"] }

anda_engine/src/extension/extractor.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -283,9 +283,9 @@ mod tests {
283283
assert_eq!(definition.name, "teststruct_extractor");
284284
let s = serde_json::to_string(&definition).unwrap();
285285
println!("{}", s);
286-
// {"name":"teststruct_extractor","description":"Extract structured data from text using LLMs.","parameters":{"properties":{"prompt":{"description":"optimized prompt or message.","type":"string"}},"required":["prompt"],"type":"object"}}
286+
// {"name":"teststruct_extractor","description":"Extract structured data from text using LLMs.","parameters":{"type":"object","properties":{"prompt":{"type":"string","description":"optimized prompt or message."}},"required":["prompt"]}}
287287
assert!(s.contains(
288-
r#""parameters":{"properties":{"prompt":{"description":"optimized prompt or message.","type":"string"}},"required":["prompt"],"type":"object"}}"#
288+
r#""parameters":{"type":"object","properties":{"prompt":{"type":"string","description":"optimized prompt or message."}},"required":["prompt"]}}"#
289289
));
290290
assert!(!s.contains("$schema"));
291291
}

anda_engine/src/model/gemini/types.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use anda_core::{
22
AgentOutput, BoxError, ByteBufB64, ContentPart, FunctionDefinition, Message,
3-
Usage as ModelUsage,
3+
Usage as ModelUsage, model::serialize_optional_openapi_schema_ordered,
44
};
55
use serde::{Deserialize, Serialize};
66
use serde_json::{Map, Value, json};
@@ -652,9 +652,11 @@ pub struct FunctionDeclaration {
652652
pub description: String,
653653

654654
#[serde(skip_serializing_if = "Option::is_none")]
655+
#[serde(serialize_with = "serialize_optional_openapi_schema_ordered")]
655656
pub parameters: Option<Value>,
656657

657658
#[serde(skip_serializing_if = "Option::is_none")]
659+
#[serde(serialize_with = "serialize_optional_openapi_schema_ordered")]
658660
pub response: Option<Value>,
659661
}
660662

anda_engine/src/model/openai/types.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
use anda_core::{AgentOutput, BoxError, ContentPart, Json, Message, Usage as ModelUsage};
1+
use anda_core::{
2+
AgentOutput, BoxError, ContentPart, Json, Message, Usage as ModelUsage,
3+
model::serialize_openapi_schema_ordered,
4+
};
25
use serde::{Deserialize, Serialize};
36
use serde_json::{Map, json};
47

@@ -386,6 +389,7 @@ pub struct ToolDefinition {
386389
/// Tool name
387390
pub name: String,
388391
/// Parameters - this should be a JSON schema. Tools should additionally ensure an "additionalParameters" field has been added with the value set to false, as this is required if using OpenAI's strict mode (enabled by default).
392+
#[serde(serialize_with = "serialize_openapi_schema_ordered")]
389393
pub parameters: Json,
390394
/// Whether to use strict mode. Enabled by default as it allows for improved efficiency.
391395
pub strict: bool,

0 commit comments

Comments
 (0)