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
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1389,6 +1389,36 @@ make example-chat

This starts an interactive chat session with tool support including web search capabilities.

### Strict Tool Schemas

A tool's schema tells the model which arguments to send, but on most
backends nothing makes the model follow it: a call with a misspelled or
unknown argument reaches the tool, and the tool rejects it. With
`EnableStrictToolSchemas`, cogito sends each tool with `"strict": true`, so a
backend that supports it constrains the arguments while the model generates
them. On LocalAI, a strict tool switches on its tool grammar. OpenAI enforces
the schema.

```go
result, err := cogito.ExecuteTools(llm, fragment,
cogito.WithMCPs(session),
cogito.EnableStrictToolSchemas)
```

Strict mode has rules of its own, so cogito adjusts each schema it sends:

- Every object gets `"additionalProperties": false`.
- Every property is required. A property that was optional becomes nullable,
and the model sends `null` to leave it out. cogito removes those `null`
values before it calls the tool, so the tool sees an omitted argument.

A tool whose schema strict mode cannot express (an object with schema-valued
or `true` `additionalProperties`, or a `oneOf`) is sent unchanged and not
strict.

MCP tools are advertised with the `additionalProperties` their server
declares, with or without this option.

### Custom Tool Implementation

See `examples/internal/search/search.go` for a complete example of implementing a DuckDuckGo search tool.
Expand Down
4 changes: 4 additions & 0 deletions guidelines.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,5 +207,9 @@ func usableTools(llm LLM, fragment Fragment, opts ...Option) (Tools, Guidelines,
}
}

if o.strictToolSchemas {
tools = strictTools(tools)
}

return tools, guidelines, prompts, nil
}
25 changes: 21 additions & 4 deletions mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,11 @@ func (t *mcpTool) Tool() openai.Tool {
Function: &openai.FunctionDefinition{
Name: t.name,
Description: t.description,
Parameters: jsonschema.Definition{
Type: jsonschema.Object,
Properties: t.props,
Required: t.inputSchema.Required,
Parameters: mcpToolParameters{
Type: jsonschema.Object,
Properties: t.props,
Required: t.inputSchema.Required,
AdditionalProperties: t.inputSchema.AdditionalProperties,
},
},
}
Expand Down Expand Up @@ -101,6 +102,22 @@ type toolInputSchema struct {
Type string `json:"type"`
Properties map[string]interface{} `json:"properties,omitempty"`
Required []string `json:"required,omitempty"`
// AdditionalProperties is kept as the server declared it. A server whose
// schema forbids unknown arguments (the go-sdk infers false for a Go
// struct) rejects a call that has one, so the model should see the same
// rule, and a strict or grammar-constrained backend can enforce it.
AdditionalProperties any `json:"additionalProperties,omitempty"`
}

// mcpToolParameters is the parameters schema an MCP tool is advertised
// with. It is not a jsonschema.Definition because that type has no
// additionalProperties field, and embedding it would not help: its value
// MarshalJSON would replace this struct's encoding.
type mcpToolParameters struct {
Type jsonschema.DataType `json:"type"`
Properties map[string]jsonschema.Definition `json:"properties"`
Required []string `json:"required,omitempty"`
AdditionalProperties any `json:"additionalProperties,omitempty"`
}

// CoerceNullableTypes is an exported alias for the same workaround so
Expand Down
11 changes: 11 additions & 0 deletions options.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type Options struct {
maxAdjustmentAttempts int
toolCallResultCallback func(ToolStatus)
strictGuidelines bool
strictToolSchemas bool
mcpSessions []*mcp.ClientSession
guidelines Guidelines
mcpPrompts bool
Expand Down Expand Up @@ -158,6 +159,16 @@ var (
o.strictGuidelines = true
}

// EnableStrictToolSchemas sends every tool that can be expressed in strict
// mode with "strict": true and a schema adjusted to the rules strict mode
// needs (see strictTool). A backend that honors it constrains the model's
// arguments to the schema while it generates them, instead of the tool
// rejecting a malformed call afterwards. LocalAI switches its tool grammar
// on for a request with a strict tool; OpenAI enforces the schema.
EnableStrictToolSchemas Option = func(o *Options) {
o.strictToolSchemas = true
}

// EnableAutoPlan enables cogito to automatically use planning if needed
EnableAutoPlan Option = func(o *Options) {
o.autoPlan = true
Expand Down
194 changes: 194 additions & 0 deletions strict.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package cogito

import (
"encoding/json"
"sort"

"github.com/sashabaranov/go-openai"
)

// strictTool advertises inner with "strict": true and a schema that follows
// the rules strict mode needs:
//
// - every object sets additionalProperties to false;
// - every property is listed in required, so a property that was optional
// becomes nullable instead, and the model sends null to leave it out.
//
// Execute removes those nulls again before it calls inner, so inner sees an
// omitted argument, as it would without strict mode. A schema that strict
// mode cannot express (an object whose additionalProperties is a schema or
// true, oneOf, a non-object top level) is sent unchanged and not strict.
type strictTool struct {
inner ToolDefinitionInterface
}

// strictTools wraps each tool in strictTool, once.
func strictTools(tools Tools) Tools {
out := make(Tools, len(tools))
for i, t := range tools {
if _, done := t.(strictTool); done {
out[i] = t
continue
}
out[i] = strictTool{inner: t}
}
return out
}

func (t strictTool) Tool() openai.Tool {
tool := t.inner.Tool()
if tool.Function == nil || tool.Function.Parameters == nil {
return tool
}
raw, err := json.Marshal(tool.Function.Parameters)
if err != nil {
return tool
}
var schema map[string]any
if err := json.Unmarshal(raw, &schema); err != nil {
return tool
}
if schema["type"] != "object" || !strictSchema(schema) {
return tool
}
fn := *tool.Function
fn.Parameters = schema
fn.Strict = true
tool.Function = &fn
return tool
}

func (t strictTool) Execute(args map[string]any) (string, any, error) {
return t.inner.Execute(dropNulls(args))
}

// strictSchema rewrites schema in place to follow strict mode's rules and
// reports whether it could. On false the caller must discard schema.
func strictSchema(schema map[string]any) bool {
if _, ok := schema["oneOf"]; ok {
return false
}
if isObjectSchema(schema) {
switch ap := schema["additionalProperties"].(type) {
case nil:
case bool:
if ap {
return false
}
default:
return false // a map type: strict mode cannot describe it
}
schema["additionalProperties"] = false

props, _ := schema["properties"].(map[string]any)
if props == nil {
props = map[string]any{}
schema["properties"] = props
}
required := map[string]bool{}
if rs, ok := schema["required"].([]any); ok {
for _, r := range rs {
if s, ok := r.(string); ok {
required[s] = true
}
}
}
names := make([]string, 0, len(props))
for name, p := range props {
ps, ok := p.(map[string]any)
if !ok {
return false // a boolean schema
}
if !strictSchema(ps) {
return false
}
if !required[name] {
props[name] = nullable(ps)
}
names = append(names, name)
}
sort.Strings(names)
req := make([]any, len(names))
for i, n := range names {
req[i] = n
}
schema["required"] = req
}
if items, ok := schema["items"].(map[string]any); ok && !strictSchema(items) {
return false
}
for _, key := range []string{"anyOf", "allOf"} {
if list, ok := schema[key].([]any); ok {
for _, s := range list {
if m, ok := s.(map[string]any); ok && !strictSchema(m) {
return false
}
}
}
}
for _, key := range []string{"$defs", "definitions"} {
if defs, ok := schema[key].(map[string]any); ok {
for _, d := range defs {
if m, ok := d.(map[string]any); ok && !strictSchema(m) {
return false
}
}
}
}
return true
}

func isObjectSchema(schema map[string]any) bool {
if schema["type"] == "object" {
return true
}
if ts, ok := schema["type"].([]any); ok {
for _, t := range ts {
if t == "object" {
return true
}
}
}
return false
}

// nullable returns schema extended to also accept null.
func nullable(schema map[string]any) map[string]any {
switch t := schema["type"].(type) {
case string:
schema["type"] = []any{t, "null"}
case []any:
for _, x := range t {
if x == "null" {
return schema
}
}
schema["type"] = append(t, "null")
default:
return map[string]any{"anyOf": []any{schema, map[string]any{"type": "null"}}}
}
if enum, ok := schema["enum"].([]any); ok {
schema["enum"] = append(enum, nil)
}
return schema
}

// dropNulls returns args without the null values strict mode makes the
// model send for an argument it leaves out, in nested objects too.
func dropNulls(args map[string]any) map[string]any {
if args == nil {
return nil
}
out := make(map[string]any, len(args))
for k, v := range args {
switch x := v.(type) {
case nil:
continue
case map[string]any:
out[k] = dropNulls(x)
default:
out[k] = v
}
}
return out
}
Loading
Loading