Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
525a257
fix(llm): bind replayed thinking blocks with drop_block
Nov 3, 2026
162aeb1
fix(llm): keep thinking binding and replay shapes intact across confi…
Nov 3, 2026
d05eb2f
feat(llm): add thinking route fact always for models that always think
Nov 3, 2026
b1cf7f7
fix(llm): bind thinking last, pass replayed block fields through, joi…
Nov 3, 2026
7550604
Merge master into fix/llm-thinking-block-binding
Nov 3, 2026
d8a5760
feat(llm): render positional notes in place and bind thinking blocks …
Nov 3, 2026
664bc4b
feat(agent): keep the request prefix stable across steps
Nov 3, 2026
7b2138b
fix(llm): render Claude developer notes independently of cache markers
Nov 3, 2026
89db370
fix(agent): fingerprint the sent prefix and fail on prompt and trait …
Nov 3, 2026
8e9efe9
fix(llm): reject non-list Bedrock binding fields from options.request
Nov 3, 2026
471e748
fix(llm): open a user turn for positional notes that follow no user turn
Nov 3, 2026
9e3c4cb
fix(agent): strip stale thinking before the request
Nov 3, 2026
f8d7317
fix(llm)!: wrap developer notes in their own instruction block
Nov 3, 2026
b7e647a
fix(agent)!: require llm 0.5.13 and validate time_format
Nov 3, 2026
170fa97
fix(agent): require the llm release that carries positional notes
Nov 3, 2026
e1aee6c
fix(agent): require llm 0.5.13
Nov 3, 2026
9a0e427
fix(llm): keep the current tool round's thinking in drop_stale_thinking
Nov 3, 2026
3ba177f
fix(agent): keep the current tool round's thinking and refresh the so…
Nov 3, 2026
b0b529f
fix(llm): a user message joining the tool-result turn keeps the round…
Nov 3, 2026
7d60edf
test(agent): keep the current round when a user message joins its res…
Nov 3, 2026
2d6feed
fix(llm): developer and positional notes end an answered assistant turn
Nov 3, 2026
855ee6b
fix(agent): fingerprint the request a checkpoint replacement sends
Nov 4, 2026
6666685
fix(agent)!: recall memory without a host opt-in
Nov 4, 2026
1939565
feat(agent): deliver each tool result to a per-call listener
Nov 4, 2026
c5cb5d3
fix(agent): cancel only unfinished calls when a result listener fails
Nov 4, 2026
9fc9104
Merge remote-tracking branch 'origin/fix/agent-stable-prefix' into lo…
Nov 4, 2026
d07d69a
Merge remote-tracking branch 'origin/feat/tool-result-listener' into …
Nov 4, 2026
d064342
fix(llm,agent): make thinking replay and request shaping safe
Nov 4, 2026
409ba2c
fix(llm): keep blank text out of recorded layouts
Nov 4, 2026
15fd56d
fix(llm): apply the blank rule to assistant text on every row
Nov 4, 2026
49f40b9
fix(llm,agent): replay no thinking behind a discarded call
Nov 4, 2026
fd1f771
fix(llm): translate foreign tool call ids on Bedrock and OpenAI
Nov 4, 2026
5d824f7
fix(agent): keep a false tool result in parallel execution
Nov 4, 2026
81f9339
fix(llm): scope a layout's call presence to its own turn
Nov 4, 2026
3971adf
fix(llm): never replay an assistant turn ending in thinking
Nov 4, 2026
ec08ea5
fix(llm,agent): keep emitted turns valid and cancel every call before…
Nov 4, 2026
dd909e5
fix(llm): keep thinking valid after discarded calls and hoisted rows,…
Nov 4, 2026
291ec53
fix(llm): a rejected layout does not inherit the previous response's …
Nov 4, 2026
84004b2
fix(llm): keep room for the answer when the output limit caps thinking
Nov 4, 2026
4a1b567
fix(llm,agent): ignore time_interval and stop mappers writing to call…
Nov 4, 2026
774ab05
Merge master into fix/llm-replay-safety
Nov 4, 2026
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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ Provides the foundational building blocks for developing concurrent AI agent app
including actor model primitives, LLM integration, HTTP services, data storage, security,
and configuration management — enabling developers to build sophisticated multiagent systems through Wippy's configuration-driven architecture.

Stored conversations with developer messages render a different prefix once after
upgrading on Claude and Bedrock. Their prompt cache restarts, and thinking blocks
bound to the old prefix no longer match. See the [LLM replay guidance](src/llm/src/README.md#positional-notes)
for handling retained thinking blocks across the upgrade.

## Modules

### Framework Modules
Expand Down
30 changes: 22 additions & 8 deletions src/agent/src/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,9 +314,10 @@ from the persisted notes.
`prefix_fingerprint` is a sha256 over the system text and tools as drivers send
them: the compiled system prompt together with ordinary (non-positional) system
messages in the conversation, which Claude and Bedrock hoist into the top-level
system, and each tool's name, description, schema and native definition
(`meta.claude`). Registry ids and registry metadata (comment, tags, title) do
not reach the provider and are not part of it. It is stable across steps while
system, without blank text, and each tool's name, description, schema and native
definition (a `meta.claude` that names a `type`). Registry ids and other registry
metadata (comment, tags, title, a `meta.claude` without a `type`) do not reach the
provider and are not part of it. It is stable across steps while
the sent system text and tools stay the same. It describes the request actually sent:
when a checkpoint callback replaces the history, the fingerprint is recomputed
from the replacement, and stale thinking in the replacement is judged against it.
Expand All @@ -339,7 +340,9 @@ thinking blocks itself:
1. The host stores `response.prefix_fingerprint` as `metadata.prefix_fingerprint`
on every row it persists from that step that carries `thinking_blocks` (the
assistant row; function call messages built through `add_function_call` carry
no metadata, so blocks for a tool round live on its assistant row).
no metadata, so blocks for a tool round live on its assistant row). It stores
`response.metadata.content_positions` on the same row when present, which keeps
a response that interleaves thinking, text and calls in its order on replay.
2. On later steps the host passes `drop_stale_thinking = true`. Before the
request, the agent removes the blocks of every message up to and including the
last one whose `metadata.prefix_fingerprint` differs from the current
Expand All @@ -352,11 +355,20 @@ The current tool round (the last assistant turn, while only its tool results,
notes and user messages joining the tool-result turn follow it) keeps its blocks
even when they are stale: the provider requires the final assistant turn of a
tool round to keep its thinking, so those blocks are left to the provider's
drop-on-mismatch handling, and only earlier turns are stripped. User content
drop-on-mismatch handling, and only earlier turns are stripped. The agent marks
them `metadata.stale_thinking` on the copies it sends: when the route's final
thinking object cannot carry a binding (`thinking_effort` 0 on a route not declared
`always`, a `none` route, `between_tools`), Claude and Bedrock leave them out of the
request instead of sending them unbound, which an enforcing model rejects on every
retry. User content
(a user message, a developer message or a positional system note) after an
assistant turn with no tool result in between ends that turn; ordinary system
messages, which drivers hoist into the top-level system, do not.

A truncated step (`truncation_reason = "tool_calls"`) discards its calls, so its
`metadata` drops them from `content_positions` along with the thinking that led to
them. Rows persisted from it replay as a turn that ends in text, or as nothing.

Stripping works on copies: the host's message tables keep their blocks, and the
stored fingerprint never reaches the provider. When a stale turn still has a
function call without a result, the step fails with that error and sends no
Expand Down Expand Up @@ -415,8 +427,8 @@ The note ends with "Use this temporal context when relevant to your responses."
empty or unknown timezone or a `format` that is not a non-empty string. `format`
is an optional Go time layout for the date and time part. The note carries the
exact minute and is never rebuilt; the framework does not inject it per step.
The removed `time_interval` setting fails compilation with an error naming
`turn_context`.
The legacy `time_interval` setting is ignored, so existing configurations still
compile. A malformed `time_format` still fails compilation.

### Token Tracking

Expand Down Expand Up @@ -479,6 +491,8 @@ end
start are delivered as soon as the failure is known.
- Delivery happens before `after_execute` wrappers run; the wrappers and the
return value still receive the complete batch keyed by call ID.
- The listener runs synchronously. Stop is observed only after a listener in
progress returns, so keep listeners short.
- A listener fails by raising or by returning a non-nil second value. The batch
then stops: no further call starts, calls still running are cancelled, results
of calls that finished but were not yet delivered are dropped without
Expand Down Expand Up @@ -658,7 +672,7 @@ return { execute = execute }

### Built-in Traits

- `wippy.agent.traits:time_aware` -- Declares `agent_options.turn_context.time` through `build_func_id`; the host renders the note with `wippy.agent:turn_context`. Configurable via context: `timezone` (default UTC), `time_format` (optional Go time layout). It contributes no prompt text; a `time_interval` setting is rejected.
- `wippy.agent.traits:time_aware` -- Declares `agent_options.turn_context.time` through `build_func_id`; the host renders the note with `wippy.agent:turn_context`. Configurable via context: `timezone` (default UTC), `time_format` (optional Go time layout). It contributes no prompt text; a legacy `time_interval` setting is ignored.

A trait build function that returns an error fails `compiler.compile` with an
error naming the trait and the function.
Expand Down
1 change: 1 addition & 0 deletions src/agent/src/_index.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ entries:
prompt: wippy.llm:prompt
llm: wippy.llm:llm
output: wippy.llm:output
thinking: wippy.llm.util:thinking
canonical: wippy.agent:canonical

# wippy.agent:canonical
Expand Down
47 changes: 34 additions & 13 deletions src/agent/src/agent.lua
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ local funcs = require("funcs")
local output = require("output")
local hash = require("hash")
local canonical = require("canonical")
local thinking = require("thinking")

type UnifiedTool = {
name: string,
Expand Down Expand Up @@ -447,31 +448,39 @@ local function has_tools_with_schemas(tools: any): boolean
end

-- What providers bind cached prefixes and thinking blocks to, as drivers send
-- it: the system text (the compiled prompt plus conversation system messages,
-- which Claude and Bedrock hoist into the top-level system) and each tool's
-- name, description, schema and native definition. Registry ids and registry
-- metadata never reach the provider. The model is left out: providers drop
-- blocks a different model cannot read on their own.
-- it: the visible system text (the compiled prompt plus conversation system
-- messages, which Claude and Bedrock hoist into the top-level system) and each
-- tool's name, description, schema and native definition. A meta.claude is a
-- native definition only when it names a type; registry ids and other registry
-- metadata never reach the provider. The model is left out: providers drop blocks
-- a different model cannot read on their own.
local function prefix_projection(messages: {any}, tools: {any}?): table
local system = {}
for _, message in ipairs(messages) do
if message.role == prompt.ROLE.SYSTEM and not prompt.is_positional_note(message) then
if type(message.content) == "string" then
table.insert(system, message.content)
if not prompt.is_blank(message.content) then
table.insert(system, message.content)
end
elseif type(message.content) == "table" then
for _, part in ipairs(message.content) do
table.insert(system, part.type == "text" and part.text or part)
if part.type ~= "text" then
table.insert(system, part)
elseif not prompt.is_blank(part.text) then
table.insert(system, part.text)
end
end
end
end
end
local projected_tools = {}
for _, tool in ipairs(tools or {}) do
local native = type(tool.meta) == "table" and tool.meta.claude or nil
table.insert(projected_tools, {
name = tool.name,
description = tool.description,
schema = tool.schema,
native = type(tool.meta) == "table" and tool.meta.claude or nil,
native = type(native) == "table" and native.type and native or nil,
})
end
return { system = system, tools = projected_tools }
Expand Down Expand Up @@ -674,16 +683,25 @@ function agent:step(prompt_builder: any, runtime_options: any): (table?, string?
end
local is_current = runtime_options.thinking_is_current
local working = prompt.new(messages)
local _, strip_err = working:drop_stale_thinking(function(msg)
if is_current then
return is_current(msg, fingerprint) == true
-- Stripping and marking read the same verdict, so a host predicate decides
-- each message once.
local verdicts = {}
local function current(msg)
if verdicts[msg] == nil then
if is_current then
verdicts[msg] = is_current(msg, fingerprint) == true
else
verdicts[msg] = msg.metadata.prefix_fingerprint == fingerprint
end
end
return msg.metadata.prefix_fingerprint == fingerprint
end)
return verdicts[msg]
end
local _, strip_err = working:drop_stale_thinking(current)
if strip_err then
return nil, output.errors.generate():kind(output.ERROR_TYPE.INVALID_REQUEST)
:message(tostring(strip_err)):build()
end
working:mark_stale_thinking(current)
return working:get_messages(), nil
end

Expand Down Expand Up @@ -791,6 +809,9 @@ function agent:step(prompt_builder: any, runtime_options: any): (table?, string?
-- to distinguish a tool retry from an empty no-tool response.
response.truncation_reason = result.tool_calls and #result.tool_calls > 0
and "tool_calls" or "empty_output"
if response.truncation_reason == "tool_calls" and type(response.metadata) == "table" then
thinking.discard_calls(response.metadata, response.result)
end
return response
end

Expand Down
8 changes: 6 additions & 2 deletions src/agent/src/canonical.lua
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
local json = require("json")

-- Serializes a value with object keys in sorted order, so equal values always
-- produce equal text. Array indices are written as keys, which keeps sparse
-- tables unambiguous.
-- produce equal text. Tables keep the array/object shape used by the wire encoder.
local canonical = {}

function canonical.encode(value: any): string
if type(value) ~= "table" then return json.encode(value) or "null" end
local keys, out = {}, {}
local encoded = json.encode(value)
if encoded and encoded:sub(1, 1) == "[" then
for _, child in ipairs(value) do out[#out+1] = canonical.encode(child) end
return "[" .. table.concat(out, ",") .. "]"
end
for key in pairs(value) do keys[#keys+1] = tostring(key) end
table.sort(keys)
for _, key in ipairs(keys) do
Expand Down
33 changes: 22 additions & 11 deletions src/agent/src/tools/caller.lua
Original file line number Diff line number Diff line change
Expand Up @@ -742,7 +742,11 @@ local function command_result(pending: PendingCall, ok: boolean?): any
if err then
return { result = nil, error = err, tool_call = pending.tool_call }
end
return { result = payload and payload:data() or nil, error = nil, tool_call = pending.tool_call }
local result = nil
if payload then
result = payload:data()
end
return { result = result, error = nil, tool_call = pending.tool_call }
end
local _, err = command:result()
return {
Expand Down Expand Up @@ -771,20 +775,21 @@ local function cancel_pending(pending: {[any]: PendingCall})
end

-- Records every pending call: responses that are already complete keep their
-- real outcome, the rest are cancelled.
-- real outcome, the rest are cancelled before any listener runs.
local function settle_after_stop(run: BatchRun, pending: {[any]: PendingCall})
local settled = {}
for call_id, call in pairs(pending) do
if run.error then break end
pending[call_id] = nil
local completed, ok = poll_response(call)
if completed then
record_result(run, call_id, command_result(call, ok))
else
call.command:cancel()
record_result(run, call_id, cancelled_result(call.tool_call))
end
if not completed then call.command:cancel() end
settled[call_id] = { call = call, completed = completed, ok = ok }
end
for call_id, outcome in pairs(settled) do
if run.error then break end
local entry = outcome.completed and command_result(outcome.call, outcome.ok)
or cancelled_result(outcome.call.tool_call)
record_result(run, call_id, entry)
end
cancel_pending(pending)
end

-- Records pending calls in completion order. Returns true when cancellation was observed.
Expand Down Expand Up @@ -883,7 +888,13 @@ local function execute_parallel(self: any, run: BatchRun, context: table?, valid
end
end

if entry then record_result(run, call_id, entry) end
if entry then
if cancelled or cancellation_requested(cancel) then
cancelled = true
settle_after_stop(run, pending)
end
if not run.error then record_result(run, call_id, entry) end
end
if run.error then
cancel_pending(pending)
return
Expand Down
4 changes: 0 additions & 4 deletions src/agent/src/traits/init_time_aware.lua
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,6 @@ type TimeAwareResult = {
-- Declares the per-turn time context; hosts render it with
-- wippy.agent:turn_context and persist it with each user turn.
local function execute(base_prompt: string?): (TimeAwareResult?, string?)
if ctx.get("time_interval") ~= nil then
return nil, "time_aware no longer rounds the time: remove time_interval; hosts render the exact minute "
.. "from agent_options.turn_context with wippy.agent:turn_context"
end
-- The trait context merges with the agent context, so the layout key is
-- namespaced: a generic "format" there belongs to someone else.
local format = ctx.get("time_format")
Expand Down
23 changes: 23 additions & 0 deletions src/agent/test/_index.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,28 @@ entries:
tool_caller: wippy.agent.tools:caller
method: run_tests

# app:replay_safety_test
- name: replay_safety_test
kind: function.lua
meta:
name: Agent Replay Safety Test
type: test
group: Agent Framework
source: file://replay_safety_test.lua
modules: [json]
imports:
prompt: wippy.llm:prompt
agent: wippy.agent:agent
llm: wippy.llm:llm
claude_generate: wippy.llm.claude:generate
bedrock_generate: wippy.llm.bedrock:generate
claude_mapper: wippy.llm.claude:mapper
claude_client: wippy.llm.claude:client
bedrock_mapper: wippy.llm.bedrock:mapper
bedrock_client: wippy.llm.bedrock:client
test: wippy.test:test
method: run_tests

# app:context_test
- name: context_test
kind: function.lua
Expand Down Expand Up @@ -374,6 +396,7 @@ entries:
imports:
test: wippy.test:test
init_time_aware: wippy.agent.traits:init_time_aware
compiler: wippy.agent.compiler:compiler
method: run_tests

- name: turn_context_test
Expand Down
Loading
Loading