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
97 changes: 97 additions & 0 deletions demos/structured-outputs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Structured Outputs

A working demonstration of `llm:chat-with-schema`, `llm:chat-json`, and `llm:get` —
constraining a model's reply to a JSON Schema and reading the fields as real NetLogo
values instead of parsing a sentence.

## The Problem

`llm:chat` returns free text. A model that needs a number has to hope the phrasing
stays stable and pick it out of the string:

```netlogo
let reply llm:chat "How confident are you, 0 to 1?"
;=> "I'd say about 0.8, though it depends on the situation."
; now what? substring? position? what if it says "eighty percent"?
```

That works until the model words things differently, and then it fails quietly.

## The Fix

```netlogo
let schema "{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"eat\",\"explore\",\"rest\"]},\"confidence\":{\"type\":\"number\"}},\"required\":[\"action\",\"confidence\"]}"

let reply llm:chat-with-schema "A turtle sees food. What now?" schema
;=> [[action eat] [confidence 0.9]]

let act llm:get reply "action" ;=> "eat" constrained to the enum
let conf llm:get reply "confidence" ;=> 0.9 a NUMBER, not text
```

## What the demo shows

Six turtles forage. Each tick every turtle sends its energy and whether it is
standing on food, and gets back a reply matching the schema above.

Two consequences are visible on screen:

- **`action` is enum-constrained**, so `act-on` compares it directly. No fuzzy
matching, no fallback branch for unexpected wording.
- **`confidence` is a number**, so `recolor` writes `confidence >= 0.7`. Turtles
above the threshold turn lime, the rest orange. That comparison is only possible
because the value arrives typed.

Press **show decisions** for each turtle's parsed fields, or **raw JSON (no schema)**
to see what `llm:chat-json` gives instead — valid JSON, but still a string.

## How to Run

1. `cp config.txt.example config.txt` and add your key. `config.txt` is gitignored.
2. Open `structured-outputs.nlogox` in NetLogo 7.0.3.
3. Press **setup**, then **go**.

Structured output needs a provider that supports constrained decoding. Verified
against Groq; OpenAI, Anthropic, and Gemini also support it. Ollama depends on the
local model.

## How JSON maps into NetLogo

NetLogo has no dictionary type, so objects become `[key value]` pair lists:

| JSON | NetLogo |
|---|---|
| `{"a": 1}` | `[[a 1]]` |
| `[1, 2]` | `[1 2]` |
| `"text"` | string |
| `10` | number |
| `true` | boolean |
| `null` | `""` (NetLogo has no null) |

Nested objects are just more pair lists, so `llm:get` chains:

```netlogo
let stats llm:get reply "stats" ;=> [[alive true] [speed 10]]
let speed llm:get stats "speed" ;=> 10
```

## Gotchas

- **The schema is a JSON string, not a NetLogo list.** The escaped quotes are
unavoidable. Passing a list raises an error naming the problem.
- **A missing key raises**, listing the keys that were available. Wrap `llm:get` in
`carefully` when a field is genuinely optional.
- **Key matching is exact and case-sensitive**, because JSON keys are.
- **The schema constrains shape, not truth.** A well-formed reply can still be a bad
decision — this removes parsing failures, not model error.

## Verification

Run headless against live Groq: 12 schema-constrained calls across 2 ticks, 0
failures. Turtles standing on food chose `eat` at 0.9 confidence while others chose
`explore` at 0.8 — the replies track state rather than repeating a default.

## Related

- API reference: `docs/API-REFERENCE.md` → Structured Output
- Issue [#22](https://github.com/NetLogo/Netlogo-LLM-Extension/issues/22)
27 changes: 27 additions & 0 deletions demos/structured-outputs/config.txt.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Structured outputs demo — provider settings
#
# Copy this file to config.txt and fill in a key. config.txt is gitignored,
# so your key stays out of version control.
#
# Structured output needs a provider that supports constrained decoding.
# Verified working: Groq, OpenAI, Anthropic, Gemini. Ollama support depends
# on the local model.

# --- Groq (free tier, fast; key from https://console.groq.com/keys) ---
provider=groq
groq_api_key=gsk_REPLACE_WITH_YOUR_KEY
model=openai/gpt-oss-20b

# --- OpenAI ---
#provider=openai
#openai_api_key=sk-REPLACE_WITH_YOUR_KEY
#model=gpt-4o-mini

# --- Local Ollama, no key needed ---
#provider=ollama
#model=llama3.2:3b
#base_url=http://localhost:11434

temperature=0.0
max_tokens=300
timeout_seconds=60
270 changes: 270 additions & 0 deletions demos/structured-outputs/structured-outputs.nlogox
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
<?xml version="1.0" encoding="utf-8"?>
<model version="NetLogo 7.0.3" snapToGrid="true">
<code><![CDATA[extensions [llm]

globals [
decision-schema ;; JSON Schema every foraging decision must satisfy
parsed-count ;; replies that parsed into usable typed fields
failed-count ;; replies that did not
last-reply ;; most recent parsed reply, shown in the output area
]

turtles-own [
energy ;; drops each tick; food restores it
last-action ;; what the model told this turtle to do
confidence ;; how sure the model was — a NUMBER, used directly below
]

patches-own [
food? ;; true where there is something to eat
]

to setup
clear-all
llm:load-config "config.txt"

;; The schema is a JSON *string*, not a NetLogo list. The escaped quotes are
;; unavoidable inside a NetLogo string literal.
set decision-schema
"{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"eat\",\"explore\",\"rest\"]},\"confidence\":{\"type\":\"number\"},\"reason\":{\"type\":\"string\"}},\"required\":[\"action\",\"confidence\",\"reason\"]}"

set parsed-count 0
set failed-count 0
set last-reply ""

ask patches [
set food? (random-float 1 < 0.12)
set pcolor ifelse-value food? [green - 2] [black]
]

create-turtles 6 [
setxy random-xcor random-ycor
set shape "person"
set color white
set energy 50
set last-action "none"
set confidence 0
]
reset-ticks
end

;; Each turtle asks the model what to do, then acts on the TYPED fields of the
;; reply. No string parsing anywhere in this procedure — that is the point.
to go
ask turtles [
let reply decide
if reply != 0 [
set last-action llm:get reply "action"
set confidence llm:get reply "confidence"
act-on last-action
]
set energy energy - 1
if energy <= 0 [ set energy 50 ]
recolor
]
tick
end

;; Returns the parsed reply as [[key value] ...], or 0 if the call failed.
;; A schema-constrained reply still travels over a network, so it is wrapped.
to-report decide
let prompt (word
"You are a foraging agent. Energy: " energy
". Food on your patch: " (ifelse-value food? ["yes"] ["no"])
". Choose an action.")
let result 0
carefully
[ set result llm:chat-with-schema prompt decision-schema
set parsed-count parsed-count + 1
set last-reply result ]
[ set failed-count failed-count + 1
set result 0 ]
report result
end

;; `action` is constrained by the schema's enum, so this needs no fuzzy matching.
to act-on [action]
if action = "eat" [
if food? [ set food? false set pcolor black set energy energy + 25 ]
stop
]
if action = "explore" [ rt random 60 - 30 fd 2 stop ]
if action = "rest" [ set energy energy + 2 ]
end

;; confidence is a NUMBER, so it can drive a comparison directly. Had the reply
;; been free text, this line would need parsing and a fallback.
to recolor
set color ifelse-value (confidence >= 0.7) [lime] [orange]
end

;; Prints each turtle's most recent typed decision.
to show-decisions
clear-output
output-print "action confidence colour"
ask turtles [
output-print (word
last-action
" " precision confidence 2
" " (ifelse-value (confidence >= 0.7) ["lime"] ["orange"]))
]
output-print ""
output-print (word "last parsed reply: " last-reply)
end

;; The no-schema path: valid JSON, returned as raw text.
to show-raw-json
clear-output
carefully
[ let raw llm:chat-json
"Name two colours a turtle could be, as a JSON object with key colours."
output-print "llm:chat-json returns a STRING:"
output-print raw
output-print (word "is-string? " is-string? raw) ]
[ output-print (word "failed: " error-message) ]
end
]]></code>
<widgets>
<view x="450" y="10" width="440" height="440"
minPxcor="-12" maxPxcor="12" minPycor="-12" maxPycor="12"
patchSize="17.0" frameRate="30.0" fontSize="10"
wrappingAllowedX="true" wrappingAllowedY="true"
showTickCounter="true" tickCounterLabel="ticks" updateMode="1"/>
<button x="15" y="15" width="85" height="40" kind="Observer" display="setup" forever="false" disableUntilTicks="false">setup</button>
<button x="110" y="15" width="85" height="40" kind="Observer" display="go" forever="true" disableUntilTicks="true">go</button>
<button x="205" y="15" width="105" height="40" kind="Observer" display="go once" forever="false" disableUntilTicks="true">go</button>
<button x="320" y="15" width="115" height="40" kind="Observer" display="show decisions" forever="false" disableUntilTicks="true">show-decisions</button>
<button x="15" y="65" width="180" height="40" kind="Observer" display="raw JSON (no schema)" forever="false" disableUntilTicks="false">show-raw-json</button>
<monitor x="205" y="65" width="105" height="45" display="parsed" precision="0" fontSize="11">parsed-count</monitor>
<monitor x="320" y="65" width="115" height="45" display="failed" precision="0" fontSize="11">failed-count</monitor>
<output x="15" y="120" width="420" height="240" fontSize="11"/>
<note x="15" y="370" width="420" height="80" fontSize="11" backgroundDark="0" backgroundLight="0" textColorDark="-1" textColorLight="-16777216" markdown="false">Lime turtles reported confidence of 0.7 or more, orange ones less. That comparison works because confidence arrives as a number, not as text to be parsed.</note>
</widgets>
<info><![CDATA[## WHAT IS IT?

A demonstration of structured output: constraining a model's reply to a JSON Schema
and reading the fields as real NetLogo values.

Without it, `llm:chat` returns a sentence. A model wanting a number has to hope the
wording is stable and pick it out of the text, which fails quietly the first time the
model phrases things differently.

## HOW IT WORKS

Every tick each turtle sends its state and gets back a reply constrained to this
schema:

{"type": "object",
"properties": {
"action": {"type": "string", "enum": ["eat", "explore", "rest"]},
"confidence": {"type": "number"},
"reason": {"type": "string"}},
"required": ["action", "confidence", "reason"]}

`llm:chat-with-schema` reports the parsed reply as `[[key value] ...]`, and `llm:get`
reads a field:

let reply llm:chat-with-schema prompt decision-schema
;=> [[action eat] [confidence 0.9] [reason found food here]]

let act llm:get reply "action" ;=> "eat" a string
let conf llm:get reply "confidence" ;=> 0.9 a NUMBER

Two things follow, and both are visible on screen:

- `action` is constrained by the schema's `enum`, so `act-on` compares it directly
with no fuzzy matching or fallback branch.
- `confidence` arrives as a number, so `recolor` can write `confidence >= 0.7`.
Turtles above the threshold are lime, the rest orange.

## HOW TO USE IT

1. Put your provider settings in `config.txt` beside this model.
2. Press **setup**, then **go**.
3. Press **show decisions** to see each turtle's typed fields.
4. Press **raw JSON (no schema)** to see what `llm:chat-json` returns instead —
valid JSON, but as a string you would still have to handle yourself.

## THINGS TO NOTICE

The schema is a JSON **string**, not a NetLogo list. The escaped quotes in `setup`
are unavoidable, and passing a list instead raises an error.

Nesting has no dotted-path syntax. A nested object is another `[[key value] ...]`
list, so you call `llm:get` again on the result.

A missing key raises rather than reporting a default, and names the keys that were
available. Wrap `llm:get` in `carefully` when a field is genuinely optional.

`null` in JSON becomes an empty string, because NetLogo has no null.

## EXTENDING THE MODEL

Add a field to `decision-schema` — say a `target` object with `xcor` and `ycor` —
and read it with a second `llm:get`. Nothing else has to change.

The **failed** monitor counts replies that did not parse. Schema constraint is
enforced by the provider, so this counts network and model failures rather than
malformed JSON; a run that only ever increments **parsed** is the expected case.

## RELATED PRIMITIVES

`llm:chat-with-schema` — schema-constrained reply, parsed into nested lists
`llm:chat-json` — valid JSON with no schema, returned as a string
`llm:get` — read a key from a `[[key value] ...]` list
]]></info>
<turtleShapes>
<shape name="default" rotatable="true" editableColorIndex="0">
<polygon color="-1920102913" filled="true" marked="true">
<point x="150" y="5"/>
<point x="40" y="250"/>
<point x="150" y="205"/>
<point x="260" y="250"/>
</polygon>
</shape>
<shape name="person" rotatable="false" editableColorIndex="0">
<circle x="110" y="5" marked="true" color="-1920102913" diameter="80" filled="true"></circle>
<polygon color="-1920102913" filled="true" marked="true">
<point x="105" y="90"></point>
<point x="120" y="195"></point>
<point x="90" y="285"></point>
<point x="105" y="300"></point>
<point x="135" y="300"></point>
<point x="150" y="225"></point>
<point x="165" y="300"></point>
<point x="195" y="300"></point>
<point x="210" y="285"></point>
<point x="180" y="195"></point>
<point x="195" y="90"></point>
</polygon>
<rectangle endX="172" startY="79" marked="true" color="-1920102913" endY="94" startX="127" filled="true"></rectangle>
<polygon color="-1920102913" filled="true" marked="true">
<point x="195" y="90"></point>
<point x="240" y="150"></point>
<point x="225" y="180"></point>
<point x="165" y="105"></point>
</polygon>
<polygon color="-1920102913" filled="true" marked="true">
<point x="105" y="90"></point>
<point x="60" y="150"></point>
<point x="75" y="180"></point>
<point x="135" y="105"></point>
</polygon>
</shape>
</turtleShapes>
<linkShapes>
<shape name="default" curviness="0.0">
<lines>
<line x="-0.2" visible="false"><dash value="0.0"/><dash value="1.0"/></line>
<line x="0.0" visible="true"><dash value="1.0"/><dash value="0.0"/></line>
<line x="0.2" visible="false"><dash value="0.0"/><dash value="1.0"/></line>
</lines>
<indicator>
<shape name="link direction" rotatable="true" editableColorIndex="0">
<line startX="150" startY="150" endX="90" endY="180" marked="true" color="-1920102913"/>
<line startX="150" startY="150" endX="210" endY="180" marked="true" color="-1920102913"/>
</shape>
</indicator>
</shape>
</linkShapes>
</model>
Loading
Loading