diff --git a/src/agent/src/_index.yaml b/src/agent/src/_index.yaml index 9f43f72..0b5f052 100644 --- a/src/agent/src/_index.yaml +++ b/src/agent/src/_index.yaml @@ -163,6 +163,67 @@ entries: source: file://context_test.lua imports: context: wippy.agent:context + compiler: wippy.agent.compiler:compiler + test: wippy.test:test + method: run_tests + + # wippy.agent:lifecycle_runtime + - name: lifecycle_runtime + kind: library.lua + meta: + type: library + comment: Runtime dispatcher for trait-owned lifecycle contract bindings + source: file://lifecycle_runtime.lua + modules: + - contract + + # wippy.agent:lifecycle_runtime_test + - name: lifecycle_runtime_test + kind: function.lua + meta: + name: Agent Lifecycle Runtime Test + type: test + comment: Unit tests for optional trait-owned lifecycle contract dispatch + group: Agent Framework + tags: + - agents + - lifecycle + - traits + - contracts + - tests + source: file://lifecycle_runtime_test.lua + imports: + lifecycle_runtime: wippy.agent:lifecycle_runtime + test: wippy.test:test + method: run_tests + + # wippy.agent:checkpoint_runtime + - name: checkpoint_runtime + kind: library.lua + meta: + type: library + comment: Runtime dispatcher for trait-owned checkpoint contract bindings + source: file://checkpoint_runtime.lua + modules: + - contract + + # wippy.agent:checkpoint_runtime_test + - name: checkpoint_runtime_test + kind: function.lua + meta: + name: Agent Checkpoint Runtime Test + type: test + comment: Unit tests for optional trait-owned checkpoint contract dispatch + group: Agent Framework + tags: + - agents + - checkpoint + - traits + - contracts + - tests + source: file://checkpoint_runtime_test.lua + imports: + checkpoint_runtime: wippy.agent:checkpoint_runtime test: wippy.test:test method: run_tests @@ -288,6 +349,409 @@ entries: } format: application/schema+json + # wippy.agent:run_context + - name: run_context + kind: contract.definition + meta: + comment: Provider-neutral read access to an agent run's context, history, and prompt slices. + tags: + - agents + - context + - history + - prompt + - session + - dataflow + description: | + Read-only contract used by trait bindings, lifecycle handlers, checkpoints, + and tools that need runner state without knowing whether the agent is + executing inside a session or dataflow node. Callers pass a host ref and + a bounded selector; implementations return normalized data and range + metadata instead of exposing internal repositories. + + Selector semantics are intentionally provider-neutral: + - all: full chronological history. + - window: last N events in chronological order; omitted N defaults to 20. + - since_id: events strictly after from_id. + - range: events strictly after from_id and through to_id, inclusive. + Missing from_id starts at the beginning; missing to_id is open ended. + - checkpoint: events strictly after the supplied checkpoint_id. + - since_checkpoint: provider's current/latest checkpoint cut. + max_chars is applied after selector slicing and may return an empty + event list with truncated=true if the first selected event exceeds the + bound. + methods: + - name: get_context + description: Return the current runtime context map for the referenced agent run. + input_schemas: + - definition: | + { + "type": "object", + "properties": { + "host": { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["session", "dataflow"] }, + "session_id": { "type": "string" }, + "dataflow_id": { "type": "string" }, + "node_id": { "type": "string" }, + "run_id": { "type": "string" }, + "iteration": { "type": "integer" } + }, + "required": ["kind"] + }, + "agent": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "model": { "type": "string" } + } + } + }, + "required": ["host"] + } + format: application/schema+json + output_schemas: + - definition: | + { + "type": "object", + "properties": { + "context": { "type": "object" }, + "host": { "type": "object" }, + "agent": { "type": "object" } + }, + "required": ["context"] + } + format: application/schema+json + - name: get_history + description: Return normalized conversation/history events for a bounded selector. + input_schemas: + - definition: | + { + "type": "object", + "properties": { + "host": { "type": "object" }, + "selector": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": ["all", "window", "range", "since_id", "since_checkpoint", "checkpoint"] + }, + "from_id": { "type": "string" }, + "to_id": { "type": "string" }, + "checkpoint_id": { "type": "string" }, + "last": { "type": "integer", "minimum": 1 }, + "max_chars": { "type": "integer", "minimum": 1 } + }, + "default": { "mode": "since_checkpoint" } + } + }, + "required": ["host"] + } + format: application/schema+json + output_schemas: + - definition: | + { + "type": "object", + "properties": { + "events": { + "type": "array", + "items": { "type": "object" } + }, + "range": { + "type": "object", + "properties": { + "from_id": { "type": "string" }, + "to_id": { "type": "string" }, + "checkpoint_id": { "type": "string" } + } + }, + "truncated": { "type": "boolean" }, + "count": { "type": "integer" } + }, + "required": ["events"] + } + format: application/schema+json + - name: get_prompt + description: Render a bounded history selector as normalized prompt messages and/or text. + input_schemas: + - definition: | + { + "type": "object", + "properties": { + "host": { "type": "object" }, + "selector": { "type": "object" }, + "format": { + "type": "string", + "enum": ["messages", "text", "both"], + "default": "messages" + } + }, + "required": ["host"] + } + format: application/schema+json + output_schemas: + - definition: | + { + "type": "object", + "properties": { + "messages": { + "type": "array", + "items": { "type": "object" } + }, + "text": { "type": "string" }, + "range": { "type": "object" }, + "truncated": { "type": "boolean" } + } + } + format: application/schema+json + + # wippy.agent:lifecycle + - name: lifecycle + kind: contract.definition + meta: + comment: Trait-owned agent lifecycle hook for sessions and dataflow. + tags: [agents, traits, lifecycle, session, dataflow] + description: | + Agent-scoped lifecycle extension point. Runners call this contract for + trait-owned bindings on the currently active compiled agent. If no trait + binds lifecycle, runners continue with their built-in behavior. + + Bindings receive only stable refs: host, agent, outcome/reason, and a + run_context pointer. They do not receive repository objects or runner + internals. A binding that needs history or prompt slices opens + run_context.binding for run_context.contract and calls get_context, + get_history, or get_prompt with the supplied host. + + Returned messages are appended to the current prompt only by phases that + run before an LLM step (activate and before_step today). Durable memory, + checkpoint, or audit writes belong inside the binding implementation. + Multiple bindings may be attached by multiple traits; the compiler orders + them by priority and declaration order. + + Phase semantics: + - activate: a compiled agent becomes active in this runner. On an agent + switch, the old agent receives deactivate before the new one activates. + - before_step: just before the current agent calls the LLM; returned + messages can add prompt context for this step. + - after_step: after the agent step result is known. + - deactivate: the active agent leaves the runner because the host + finished, failed, or switched to another agent. + methods: + - name: apply + description: Apply one lifecycle phase for the active compiled agent. + input_schemas: + - definition: | + { + "type": "object", + "properties": { + "phase": { + "type": "string", + "enum": ["activate", "before_step", "after_step", "deactivate"] + }, + "host": { "type": "object" }, + "agent": { "type": "object" }, + "reason": { "type": "string" }, + "outcome": { "type": "object" }, + "refs": { "type": "object" }, + "run_context": { + "type": "object", + "properties": { + "contract": { "type": "string" }, + "binding": { "type": "string" }, + "host": { "type": "object" }, + "agent": { "type": "object" } + } + }, + "options": { "type": "object" } + }, + "required": ["phase", "host"] + } + format: application/schema+json + output_schemas: + - definition: | + { + "type": "object", + "properties": { + "messages": { "type": "array", "items": { "type": "object" } }, + "context": { "type": "object" }, + "observations": { "type": "array", "items": { "type": "object" } }, + "metadata": { "type": "object" } + } + } + format: application/schema+json + + # wippy.agent:checkpoint + - name: checkpoint + kind: contract.definition + meta: + comment: Trait-owned checkpoint provider. + tags: [agents, traits, memory, checkpoint, session, dataflow] + description: | + Canonical token-threshold checkpoint hook for sessions and dataflow. + The runner decides when a checkpoint is needed; the binding decides how + to read, summarize, and optionally persist durable memory using the + run_context contract and selector. + methods: + - name: create + input_schemas: + - definition: | + { + "type": "object", + "properties": { + "host": { "type": "object" }, + "agent": { "type": "object" }, + "reason": { "type": "string" }, + "selector": { "type": "object" }, + "run_context": { "type": "object" }, + "options": { "type": "object" }, + "context": { "type": "object" }, + "refs": { "type": "object" } + }, + "required": ["host"] + } + format: application/schema+json + output_schemas: + - definition: | + { + "type": "object", + "properties": { + "memory": { "type": "string" }, + "summary": { "type": "string" }, + "metadata": { "type": "object" }, + "tokens": { "type": "object" } + } + } + format: application/schema+json + + # wippy.agent:tool_wrapper + - name: tool_wrapper + kind: contract.definition + meta: + comment: Trait-owned wrapper around agent tool execution. + tags: + - agents + - traits + - tools + - session + - dataflow + description: | + Allows a trait to inspect or adjust tool calls before execution and inspect + tool results after execution. Session and dataflow runners supply the host + reference; the wrapper owns no memory or checkpoint lifecycle. Traits can + attach multiple focused bindings and each binding is invoked only for the + phases declared by that trait. + methods: + - name: apply + description: Apply one wrapper phase to an agent tool execution. + input_schemas: + - definition: | + { + "type": "object", + "properties": { + "phase": { + "type": "string", + "enum": ["before_execute", "after_execute"] + }, + "host": { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["session", "dataflow"] }, + "session_id": { "type": "string" }, + "dataflow_id": { "type": "string" }, + "node_id": { "type": "string" }, + "run_id": { "type": "string" }, + "iteration": { "type": "integer" } + }, + "required": ["kind"] + }, + "agent": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "model": { "type": "string" } + } + }, + "run_context": { + "type": "object", + "description": "Pointer to the provider-neutral run_context binding for this host ref.", + "properties": { + "contract": { "type": "string" }, + "binding": { "type": "string" }, + "host": { "type": "object" }, + "agent": { "type": "object" } + } + }, + "tool_calls": { + "type": "array", + "items": { "type": "object" } + }, + "tool_results": { + "type": "object" + }, + "outcome": { + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": ["continues", "completed", "failed", "compacted", "delegated"] + }, + "reason": { + "type": "string", + "enum": [ + "tool_results_recorded", + "tool_execution_failed", + "exit_tool_completed", + "delegation_requested", + "no_tools_required", + "max_iterations_reached", + "context_limit_reached", + "host_failed" + ] + } + }, + "required": ["state", "reason"] + }, + "options": { + "type": "object", + "description": "Trait-declared options for this wrapper." + } + }, + "required": ["phase", "host", "tool_calls"] + } + format: application/schema+json + output_schemas: + - definition: | + { + "type": "object", + "properties": { + "skipped": { + "type": "boolean" + }, + "tool_calls": { + "type": "array", + "items": { "type": "object" } + }, + "observations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "level": { "type": "string", "enum": ["info", "warning", "error"] }, + "code": { "type": "string" }, + "content": {}, + "metadata": { "type": "object" } + }, + "required": ["level", "code", "content"] + } + }, + "metadata": { + "type": "object" + } + } + } + format: application/schema+json + # wippy.agent:resolver - name: resolver kind: contract.definition diff --git a/src/agent/src/agent.lua b/src/agent/src/agent.lua index 364e934..1802395 100644 --- a/src/agent/src/agent.lua +++ b/src/agent/src/agent.lua @@ -26,8 +26,11 @@ type CompiledAgentSpec = { tools: {[string]: UnifiedTool}, memory: {string}, memory_contract: table?, + agent_options: table?, prompt_funcs: {table}, step_funcs: {table}, + tool_wrappers: {table}, + bindings: {[string]: {table}}?, } type TokenUsage = { @@ -48,10 +51,13 @@ type AgentRunner = { tools: {[string]: UnifiedTool}, memory: {string}, memory_contract: table?, + agent_options: table, system_prompt: string, system_message: table, prompt_funcs: {table}, step_funcs: {table}, + tool_wrappers: {table}, + bindings: {[string]: {table}}, total_tokens: TokenUsage, } @@ -414,9 +420,12 @@ function agent.new(compiled_spec: any): (any, string?) tools = compiled_spec.tools or {}, memory = compiled_spec.memory or {}, memory_contract = compiled_spec.memory_contract, + agent_options = compiled_spec.agent_options or {}, system_prompt = compiled_spec.prompt or "", prompt_funcs = compiled_spec.prompt_funcs or {}, step_funcs = compiled_spec.step_funcs or {}, + tool_wrappers = compiled_spec.tool_wrappers or {}, + bindings = compiled_spec.bindings or {}, total_tokens = { prompt = 0, completion = 0, diff --git a/src/agent/src/agent_test.lua b/src/agent/src/agent_test.lua index c08d402..5dfede0 100644 --- a/src/agent/src/agent_test.lua +++ b/src/agent/src/agent_test.lua @@ -48,7 +48,8 @@ local function define_tests() memory = { "You are designed for testing" }, memory_contract = nil, prompt_funcs = {}, - step_funcs = {} + step_funcs = {}, + tool_wrappers = {} } local compiled_spec_with_aliases = { @@ -84,7 +85,8 @@ local function define_tests() memory = {}, memory_contract = nil, prompt_funcs = {}, - step_funcs = {} + step_funcs = {}, + tool_wrappers = {} } local compiled_spec_with_delegates = { @@ -126,7 +128,8 @@ local function define_tests() memory = {}, memory_contract = nil, prompt_funcs = {}, - step_funcs = {} + step_funcs = {}, + tool_wrappers = {} } local compiled_spec_with_tool_schemas = { @@ -168,7 +171,8 @@ local function define_tests() memory = {}, memory_contract = nil, prompt_funcs = {}, - step_funcs = {} + step_funcs = {}, + tool_wrappers = {} } local compiled_spec_with_memory = { @@ -205,7 +209,48 @@ local function define_tests() } }, prompt_funcs = {}, - step_funcs = {} + step_funcs = {}, + tool_wrappers = {} + } + + local compiled_spec_with_tool_wrappers = { + id = "tool-wrapper-agent", + name = "Tool Wrapper Agent", + description = "Agent with trait-owned tool wrappers", + model = "gpt-4o-mini", + prompt = "You are an agent with tool wrappers.", + tools = {}, + memory = {}, + memory_contract = nil, + agent_options = { + compact = { + token_threshold = 16000, + function_id = "agent.compact:test" + }, + checkpoint = { + token_threshold = 32000 + } + }, + prompt_funcs = {}, + step_funcs = {}, + tool_wrappers = { + { + id = "audit_tools", + trait_id = "audit_trait", + phases = { "before_execute", "after_execute" }, + binding = "test.wrapper:audit_provider", + priority = 20, + strict = true, + context = { + agent_id = "tool-wrapper-agent", + trait_id = "audit_trait", + policy = "readonly" + }, + options = { + max_calls = 4 + } + } + } } before_each(function() @@ -517,6 +562,8 @@ local function define_tests() test.eq(test_agent.system_prompt, "You are a helpful test agent.\n\n## Your memory contains:\n- You are designed for testing") test.not_nil(test_agent.tools["calculator"]) test.not_nil(test_agent.tools["weather"]) + test.not_nil(test_agent.tool_wrappers) + test.eq(#test_agent.tool_wrappers, 0) test.eq(test_agent.total_tokens.total, 0) end) @@ -543,6 +590,35 @@ local function define_tests() test.eq(test_agent.temperature, 0) test.eq(test_agent.thinking_effort, 0) test.is_nil(next(test_agent.tools)) -- Empty tools table + test.not_nil(test_agent.tool_wrappers) + test.eq(#test_agent.tool_wrappers, 0) + end) + + it("should preserve tool wrapper specs from compiled specification", function() + local test_agent = agent.new(compiled_spec_with_tool_wrappers) + + test.not_nil(test_agent) + test.not_nil(test_agent.tool_wrappers) + test.eq(#test_agent.tool_wrappers, 1) + + local wrapper = test_agent.tool_wrappers[1] + test.eq(wrapper.id, "audit_tools") + test.eq(wrapper.binding, "test.wrapper:audit_provider") + test.eq(wrapper.phases[1], "before_execute") + test.eq(wrapper.phases[2], "after_execute") + test.is_true(wrapper.strict) + test.eq(wrapper.context.policy, "readonly") + test.eq(wrapper.options.max_calls, 4) + end) + + it("should preserve agent options from compiled specification", function() + local test_agent = agent.new(compiled_spec_with_tool_wrappers) + + test.not_nil(test_agent) + test.not_nil(test_agent.agent_options) + test.eq(test_agent.agent_options.compact.token_threshold, 16000) + test.eq(test_agent.agent_options.compact.function_id, "agent.compact:test") + test.eq(test_agent.agent_options.checkpoint.token_threshold, 32000) end) end) @@ -682,6 +758,27 @@ local function define_tests() test.is_nil(result.tool_calls) -- Tool calls should be cleared for delegates end) + it("should keep tool wrapper metadata stable when a delegate redirect is emitted", function() + local redirect_agent_spec = {} + for k, v in pairs(compiled_spec_with_delegates) do + redirect_agent_spec[k] = v + end + redirect_agent_spec.tool_wrappers = compiled_spec_with_tool_wrappers.tool_wrappers + redirect_agent_spec.agent_options = compiled_spec_with_tool_wrappers.agent_options + + local test_agent = agent.new(redirect_agent_spec) + local prompt_builder = mock_prompt.new() + + prompt_builder:add_user("Please analyze this sales data for trends") + local result = test_agent:step(prompt_builder) + + test.not_nil(result.delegate_calls) + test.eq(#result.delegate_calls, 1) + test.eq(#test_agent.tool_wrappers, 1) + test.eq(test_agent.tool_wrappers[1].binding, "test.wrapper:audit_provider") + test.eq(test_agent.agent_options.compact.token_threshold, 16000) + end) + it("should preserve tool calls when no delegates are triggered", function() local test_agent = agent.new(compiled_spec_with_delegates) local prompt_builder = mock_prompt.new() @@ -1228,4 +1325,4 @@ local function define_tests() end) end -return require("test").run_cases(define_tests) \ No newline at end of file +return require("test").run_cases(define_tests) diff --git a/src/agent/src/checkpoint_runtime.lua b/src/agent/src/checkpoint_runtime.lua new file mode 100644 index 0000000..88dbb3e --- /dev/null +++ b/src/agent/src/checkpoint_runtime.lua @@ -0,0 +1,181 @@ +local contract = require("contract") + +type CheckpointBindingSpec = { + id: string?, + trait_id: string?, + kind: string?, + contract: string?, + binding: string?, + context: table?, + options: table?, + priority: number?, + strict: boolean?, + order: number?, +} + +type CheckpointPayload = { + host: table, + agent: table?, + reason: string?, + selector: table?, + refs: table?, + run_context: table?, + options: table?, + context: table?, +} + +local checkpoint_runtime = { + CONTRACT_ID = "wippy.agent:checkpoint", +} + +checkpoint_runtime._contract = nil + +local function get_contract(): any + return checkpoint_runtime._contract or contract +end + +local function normalize_bindings(bindings: any): {CheckpointBindingSpec} + local raw = bindings + if type(raw) == "table" and type(raw.checkpoint) == "table" then + raw = raw.checkpoint + end + + local normalized: {CheckpointBindingSpec} = {} + for _, binding in ipairs(raw or {}) do + if type(binding) == "table" then + normalized[#normalized + 1] = binding + end + end + + table.sort(normalized, function(a, b) + local ap = tonumber(a.priority) or 100 + local bp = tonumber(b.priority) or 100 + if ap == bp then + return (tonumber(a.order) or 0) < (tonumber(b.order) or 0) + end + return ap < bp + end) + + return normalized :: {CheckpointBindingSpec} +end + +local function copy_payload(payload: CheckpointPayload?): CheckpointPayload + local out = {} + for k, v in pairs(payload or {}) do + out[k] = v + end + return out :: CheckpointPayload +end + +local function checkpoint_text(result: any): string? + if type(result) ~= "table" then + return nil + end + local value = result.memory or result.summary + if type(value) == "string" and value ~= "" then + return value + end + return nil +end + +local function call_binding(binding_spec: CheckpointBindingSpec, payload: CheckpointPayload): (table?, string?) + local binding = binding_spec.binding + if type(binding) ~= "string" or binding == "" then + return nil, "checkpoint binding is required" + end + + local contract_id = binding_spec.contract + if type(contract_id) ~= "string" or contract_id == "" then + contract_id = checkpoint_runtime.CONTRACT_ID + end + if contract_id ~= checkpoint_runtime.CONTRACT_ID then + return nil, "unsupported checkpoint contract: " .. tostring(contract_id) + end + + local contract_def, contract_err = get_contract().get(checkpoint_runtime.CONTRACT_ID) + if contract_err or not contract_def then + return nil, "failed to load checkpoint contract: " .. tostring(contract_err or "not found") + end + + local opener = contract_def + if opener.with_context then + opener = opener:with_context(binding_spec.context or {}) + end + + local instance, open_err = opener:open(tostring(binding)) + if open_err or not instance then + return nil, "failed to open checkpoint binding: " .. tostring(open_err or "no instance") + end + + local result, apply_err = instance:create(payload) + + if apply_err then + return nil, tostring(apply_err) + end + + if type(result) ~= "table" then + return nil, "checkpoint binding returned no result" + end + + if not checkpoint_text(result) then + return nil, "checkpoint binding returned empty memory" + end + + return result :: table, nil +end + +function checkpoint_runtime.create(bindings: any, payload: any): (table, string?) + local base_payload: CheckpointPayload = copy_payload(payload :: CheckpointPayload?) + local normalized = normalize_bindings(bindings) + + local summary = { + applied = 0, + skipped = 0, + result = nil :: table?, + metadata = {}, + errors = {}, + } + + if #normalized == 0 then + return summary, nil + end + + if type(base_payload.host) ~= "table" or type(base_payload.host.kind) ~= "string" or base_payload.host.kind == "" then + return summary, "checkpoint host is required" + end + + for _, binding in ipairs(normalized) do + local current_payload: CheckpointPayload = copy_payload(base_payload) + current_payload.options = binding.options or {} + current_payload.context = binding.context or {} + + local result, err = call_binding(binding, current_payload :: CheckpointPayload) + if err then + local checkpoint_error = { + binding_id = binding.id, + trait_id = binding.trait_id, + error = tostring(err), + strict = binding.strict == true, + } + summary.errors[#summary.errors + 1] = checkpoint_error + if binding.strict == true then + return summary, tostring(err) + end + else + summary.applied = summary.applied + 1 + summary.result = result + if result.metadata ~= nil then + summary.metadata[#summary.metadata + 1] = { + binding_id = binding.id, + trait_id = binding.trait_id, + metadata = result.metadata, + } + end + return summary, nil + end + end + + return summary, nil +end + +return checkpoint_runtime diff --git a/src/agent/src/checkpoint_runtime_test.lua b/src/agent/src/checkpoint_runtime_test.lua new file mode 100644 index 0000000..e4d3e4f --- /dev/null +++ b/src/agent/src/checkpoint_runtime_test.lua @@ -0,0 +1,218 @@ +local function define_tests() + describe("Agent checkpoint runtime", function() + local checkpoint_runtime + local calls + local behaviors + + before_each(function() + checkpoint_runtime = require("checkpoint_runtime") + calls = {} + behaviors = {} + + checkpoint_runtime._contract = { + get = function(contract_id) + if contract_id ~= "wippy.agent:checkpoint" then + return nil, "unexpected contract: " .. tostring(contract_id) + end + + local function opener_for(binding_context) + return { + open = function(_, binding_id) + return { + create = function(_, payload) + calls[#calls + 1] = { + contract_id = contract_id, + method = "create", + binding = binding_id, + context = binding_context or {}, + payload = payload, + } + local behavior = behaviors[binding_id] + if behavior then + return behavior(payload, binding_context or {}) + end + return { memory = "checkpoint memory" }, nil + end, + }, nil + end + } + end + + return { + with_context = function(_, binding_context) + return opener_for(binding_context) + end, + open = function(_, binding_id) + return opener_for({}):open(binding_id) + end, + }, nil + end + } + end) + + after_each(function() + checkpoint_runtime._contract = nil + checkpoint_runtime = nil + calls = nil + behaviors = nil + end) + + local function payload() + return { + host = { + kind = "session", + session_id = "s1" + }, + agent = { + id = "agent1", + model = "model1" + }, + reason = "token_threshold_exceeded", + selector = { + mode = "since_checkpoint" + }, + run_context = { + contract = "wippy.agent:run_context", + binding = "host.run_context:binding" + } + } + end + + it("is a no-op when no checkpoint bindings are configured", function() + local result, err = checkpoint_runtime.create(nil, payload()) + + test.is_nil(err) + test.eq(result.applied, 0) + test.eq(result.skipped, 0) + test.eq(#calls, 0) + test.is_nil(result.result) + end) + + it("calls the highest-priority checkpoint binding with context and options", function() + behaviors.early = function(in_payload, binding_context) + return { + memory = "trait checkpoint", + metadata = { + namespace = binding_context.namespace, + max_items = in_payload.options.max_items, + run_context = in_payload.run_context.binding, + } + }, nil + end + + local result, err = checkpoint_runtime.create({ + { + id = "late", + binding = "late", + priority = 50, + }, + { + id = "early", + trait_id = "trait.memory", + binding = "early", + priority = 10, + context = { + namespace = "ops" + }, + options = { + max_items = 5 + } + } + }, payload()) + + test.is_nil(err) + test.eq(result.applied, 1) + test.eq(#calls, 1) + test.eq(calls[1].binding, "early") + test.eq(calls[1].method, "create") + test.eq(calls[1].context.namespace, "ops") + test.eq(calls[1].payload.options.max_items, 5) + test.eq(calls[1].payload.context.namespace, "ops") + test.eq(result.result.memory, "trait checkpoint") + test.eq(result.metadata[1].trait_id, "trait.memory") + end) + + it("falls through non-strict errors and uses the next binding", function() + behaviors.bad = function() + return nil, "temporary failure" + end + behaviors.good = function() + return { summary = "fallback summary" }, nil + end + + local result, err = checkpoint_runtime.create({ + { + id = "bad", + binding = "bad", + priority = 1, + }, + { + id = "good", + binding = "good", + priority = 2, + }, + }, payload()) + + test.is_nil(err) + test.eq(result.applied, 1) + test.eq(#result.errors, 1) + test.eq(result.errors[1].binding_id, "bad") + test.eq(result.result.summary, "fallback summary") + test.eq(#calls, 2) + end) + + it("fails on strict checkpoint errors", function() + behaviors.strict_bad = function() + return nil, "strict failure" + end + + local result, err = checkpoint_runtime.create({ + { + id = "strict_bad", + binding = "strict_bad", + strict = true, + } + }, payload()) + + test.eq(err, "strict failure") + test.eq(result.applied, 0) + test.eq(#result.errors, 1) + test.eq(result.errors[1].strict, true) + end) + + it("rejects non-checkpoint contracts instead of aliasing unrelated vocabulary", function() + local result, err = checkpoint_runtime.create({ + { + id = "wrong", + contract = "wippy.agent:memory", + binding = "wrong", + } + }, payload()) + + test.is_nil(err) + test.eq(result.applied, 0) + test.eq(#result.errors, 1) + test.contains(result.errors[1].error, "unsupported checkpoint contract") + test.eq(#calls, 0) + end) + + it("rejects configured handlers when the host ref is missing", function() + local result, err = checkpoint_runtime.create({ + { + id = "handler", + binding = "handler", + } + }, {}) + + test.eq(err, "checkpoint host is required") + test.eq(result.applied, 0) + test.eq(#calls, 0) + end) + end) +end + +return { + run_tests = function() + return require("test").run_cases(define_tests) + end +} diff --git a/src/agent/src/compiler/compiler.lua b/src/agent/src/compiler/compiler.lua index 109cb96..9f6207a 100644 --- a/src/agent/src/compiler/compiler.lua +++ b/src/agent/src/compiler/compiler.lua @@ -4,7 +4,7 @@ local funcs = require("funcs") local json = require("json") type ToolDef = string | { id: string, alias: string?, context: table?, description: string?, schema: table? } -type TraitConfig = string | { id: string, context: table? } +type TraitConfig = string | { id: string, context: table?, options: table?, agent_options: table? } type DelegateConfig = { id: string, name: string?, rule: string?, context: table? } type RawAgentSpec = { @@ -22,7 +22,8 @@ type RawAgentSpec = { delegates: {DelegateConfig}?, context: table?, memory: table?, - memory_contract: string?, + memory_contract: any?, + agent_options: table?, } type CompilerConfig = { @@ -42,6 +43,31 @@ type CompiledTool = { type PromptFunc = { trait_id: string, func_id: string, context: table } type StepFunc = { trait_id: string, func_id: string, context: table } +type ToolWrapperSpec = { + id: string?, + trait_id: string, + phases: {string}, + binding: string, + context: table, + options: table, + priority: number, + strict: boolean?, + order: number, +} +type BindingSpec = { + id: string?, + kind: string, + trait_id: string?, + contract: string, + binding: string, + phases: {string}, + context: table, + options: table, + priority: number, + strict: boolean?, + order: number, + source: string?, +} type CompiledAgentSpec = { id: string, @@ -55,9 +81,12 @@ type CompiledAgentSpec = { prompt: string, tools: {[string]: CompiledTool}, memory: table, - memory_contract: string?, + memory_contract: any?, + agent_options: table, prompt_funcs: {PromptFunc}, step_funcs: {StepFunc}, + tool_wrappers: {ToolWrapperSpec}, + bindings: {[string]: {BindingSpec}}, } local compiler = {} @@ -131,6 +160,670 @@ local function merge_config(user_config: table?): CompilerConfig return config end +local function deep_copy(value: any): any + if type(value) ~= "table" then + return value + end + + local copied = {} + for key, item in pairs(value) do + copied[key] = deep_copy(item) + end + return copied +end + +local function is_map(value: any): boolean + return type(value) == "table" and value[1] == nil +end + +local function merge_agent_options(base_options: any, override_options: any): table + local merged = {} + + if type(base_options) == "table" then + for key, value in pairs(base_options) do + merged[key] = deep_copy(value) + end + end + + if type(override_options) == "table" then + for key, value in pairs(override_options) do + if is_map(merged[key]) and is_map(value) then + merged[key] = merge_agent_options(merged[key], value) + else + merged[key] = deep_copy(value) + end + end + end + + return merged +end + +local function normalize_binding_kind(kind_hint: any, raw_binding: any): string? + local raw_kind = raw_binding and (raw_binding.kind or raw_binding.type) or kind_hint + if type(raw_kind) ~= "string" or raw_kind == "" then + return nil + end + + local aliases = { + checkpoint = "checkpoint", + lifecycle = "lifecycle", + memory = "memory", + tool_wrapper = "tool_wrapper", + tool_wrappers = "tool_wrapper", + } + + return aliases[raw_kind] or raw_kind +end + +local function normalize_binding_phases(raw_binding: any): {string} + local phases: {string} = {} + local raw_phases = raw_binding and (raw_binding.phases or raw_binding.phase) + + if type(raw_phases) == "string" then + phases[#phases + 1] = raw_phases + return phases :: {string} + end + + if type(raw_phases) == "table" then + for _, phase in ipairs(raw_phases) do + if type(phase) == "string" and phase ~= "" then + phases[#phases + 1] = phase + end + end + end + + return phases :: {string} +end + +local function normalize_raw_binding(kind_hint: any, raw_binding: any): any? + if type(raw_binding) ~= "table" then + return nil + end + + local contract_id = raw_binding.contract or raw_binding.contract_id + local binding_id = raw_binding.binding or raw_binding.binding_id or raw_binding.implementation_id + if type(contract_id) ~= "string" or contract_id == "" then + return nil + end + if type(binding_id) ~= "string" or binding_id == "" then + return nil + end + + local kind = normalize_binding_kind(kind_hint, raw_binding) + if type(kind) ~= "string" or kind == "" then + return nil + end + + return { + id = raw_binding.id or raw_binding.name, + kind = kind, + contract = contract_id, + binding = binding_id, + phases = normalize_binding_phases(raw_binding), + context = type(raw_binding.context) == "table" and raw_binding.context or {}, + options = type(raw_binding.options) == "table" and raw_binding.options or {}, + priority = tonumber(raw_binding.priority) or 100, + strict = raw_binding.strict == true, + } +end + +local function collect_raw_bindings(out: {any}, kind_hint: any, raw_bindings: any) + if type(raw_bindings) ~= "table" then + return + end + + if raw_bindings[1] ~= nil then + for _, raw_binding in ipairs(raw_bindings) do + local normalized = normalize_raw_binding(kind_hint, raw_binding) + if normalized then + out[#out + 1] = normalized + end + end + return + end + + local direct = normalize_raw_binding(kind_hint, raw_bindings) + if direct then + out[#out + 1] = direct + return + end + + for key, nested in pairs(raw_bindings) do + collect_raw_bindings(out, key, nested) + end +end + +local function raw_bindings_from_trait(trait_def: any): {any} + local out = {} + if type(trait_def) ~= "table" then + return out + end + + collect_raw_bindings(out, nil, trait_def.bindings) + collect_raw_bindings(out, nil, trait_def.binding) + return out +end + +local function list_from_map_or_array(value: any): {string} + local out: {string} = {} + if type(value) == "string" and value ~= "" then + out[#out + 1] = value + return out + end + + if type(value) ~= "table" then + return out + end + + if value[1] ~= nil then + for _, item in ipairs(value) do + if type(item) == "string" and item ~= "" then + out[#out + 1] = item + end + end + return out + end + + for key, enabled in pairs(value) do + if type(key) == "string" and key ~= "" and enabled ~= false then + out[#out + 1] = key + end + end + return out +end + +local function set_from_list(values: {string}): {[string]: boolean} + local out = {} + for _, value in ipairs(values) do + out[value] = true + end + return out +end + +local function behaviors_from_trait(trait_def: any): {any} + local out = {} + if type(trait_def) ~= "table" or type(trait_def.behaviors) ~= "table" then + return out + end + + if trait_def.behaviors[1] ~= nil then + for _, behavior in ipairs(trait_def.behaviors) do + if type(behavior) == "table" then + out[#out + 1] = behavior + end + end + return out + end + + for id, behavior in pairs(trait_def.behaviors) do + if type(behavior) == "table" then + local normalized = deep_copy(behavior) + if normalized.id == nil and type(id) == "string" then + normalized.id = id + end + out[#out + 1] = normalized + end + end + return out +end + +local function behavior_handler(behavior: any, handler_name: string): string? + if type(behavior) ~= "table" then + return nil + end + + if type(behavior.handlers) == "table" then + local handler = behavior.handlers[handler_name] + if type(handler) == "string" and handler ~= "" then + return handler + end + end + + local handler = behavior.handler + if type(handler) == "string" and handler ~= "" then + return handler + end + + return nil +end + +local function append_binding_spec( + bindings: {[string]: {BindingSpec}}, + kind: string, + spec: BindingSpec +) + bindings[kind] = bindings[kind] or {} + spec.kind = kind + spec.order = #bindings[kind] + 1 + bindings[kind][#bindings[kind] + 1] = spec +end + +local function merge_binding_options(trait_options: any, attachment_options: any, binding: any): table + local merged = merge_agent_options(trait_options, binding and binding.options or nil) + if type(binding) == "table" and type(binding.context) == "table" then + merged = merge_agent_options(merged, binding.context.options) + end + merged = merge_agent_options(merged, attachment_options) + return merged +end + +local function attach_options_to_context(context: table, options: any): table + if type(options) == "table" and next(options) ~= nil then + context.options = merge_agent_options(context.options, options) + end + return context +end + +local TOOL_WRAPPER_PHASES = { + before_execute = true, + after_execute = true, +} + +local TOOL_WRAPPER_PHASE_ORDER = { "before_execute", "after_execute" } + +local LIFECYCLE_PHASES = { + activate = true, + before_step = true, + after_step = true, + deactivate = true, +} + +local LIFECYCLE_PHASE_ORDER = { "activate", "before_step", "after_step", "deactivate" } + +local CHECKPOINT_AGENT_OPTION_KEYS = { + function_id = true, + max_memory_chars = true, + max_tokens = true, + strict = true, + token_threshold = true, +} + +local function normalize_tool_wrapper_phases(raw_hook: any): {string} + local phases: {string} = {} + local raw_phases = raw_hook and (raw_hook.phases or raw_hook.phase) + + if raw_phases == nil then + return { "before_execute", "after_execute" } + end + + if type(raw_phases) == "string" then + if TOOL_WRAPPER_PHASES[raw_phases] then + phases[#phases + 1] = raw_phases + end + return phases :: {string} + end + + if type(raw_phases) == "table" then + for _, phase in ipairs(raw_phases) do + if type(phase) == "string" and TOOL_WRAPPER_PHASES[phase] then + phases[#phases + 1] = phase + end + end + end + + return phases :: {string} +end + +local function append_raw_tool_wrapper(out: {any}, raw_wrapper: any) + if type(raw_wrapper) ~= "table" then + return + end + + local binding = raw_wrapper.binding or raw_wrapper.binding_id or raw_wrapper.implementation_id + if type(binding) ~= "string" or binding == "" then + return + end + + local phases = normalize_tool_wrapper_phases(raw_wrapper) + if #phases == 0 then + return + end + + out[#out + 1] = { + id = raw_wrapper.id or raw_wrapper.name, + phases = phases, + binding = binding, + context = type(raw_wrapper.context) == "table" and raw_wrapper.context or {}, + options = type(raw_wrapper.options) == "table" and raw_wrapper.options or {}, + priority = tonumber(raw_wrapper.priority) or 100, + strict = raw_wrapper.strict == true, + } +end + +local function collect_raw_tool_wrappers(out: {any}, raw_wrappers: any) + if type(raw_wrappers) ~= "table" then + return + end + + if raw_wrappers[1] ~= nil then + for _, raw_wrapper in ipairs(raw_wrappers) do + append_raw_tool_wrapper(out, raw_wrapper) + end + return + end + + append_raw_tool_wrapper(out, raw_wrappers) +end + +local function tool_wrappers_from_trait(trait_def: any): {any} + local out = {} + if type(trait_def) ~= "table" then + return out + end + + collect_raw_tool_wrappers(out, trait_def.tool_wrappers) + collect_raw_tool_wrappers(out, trait_def.tool_wrapper) + return out +end + +local function append_tool_wrappers( + tool_wrappers: {ToolWrapperSpec}, + trait_id: string, + trait_def: any, + trait_context: table, + raw_spec: any, + config: CompilerConfig +) + local wrappers = tool_wrappers_from_trait(trait_def) + if #wrappers == 0 then + return + end + + for _, wrapper in ipairs(wrappers) do + local wrapper_context = config.context_merger( + trait_context or {}, + raw_spec.context or {}, + wrapper.context or {} + ) + wrapper_context.agent_id = raw_spec.id + wrapper_context.trait_id = trait_id + + tool_wrappers[#tool_wrappers + 1] = { + id = wrapper.id, + trait_id = trait_id, + phases = wrapper.phases, + binding = wrapper.binding, + context = wrapper_context, + options = wrapper.options or {}, + priority = wrapper.priority or 100, + strict = wrapper.strict == true, + order = #tool_wrappers + 1, + } + end +end + +local function append_bindings( + bindings: {[string]: {BindingSpec}}, + trait_id: string, + trait_def: any, + trait_context: table, + attachment_options: table, + raw_spec: any, + config: CompilerConfig +) + local raw_bindings = raw_bindings_from_trait(trait_def) + if #raw_bindings == 0 then + return + end + + local trait_options = type(trait_def.options) == "table" and trait_def.options or {} + + for _, binding in ipairs(raw_bindings) do + local binding_context = config.context_merger( + trait_context or {}, + raw_spec.context or {}, + binding.context or {} + ) + binding_context.agent_id = raw_spec.id + binding_context.trait_id = trait_id + + local binding_options = merge_binding_options(trait_options, attachment_options, binding) + attach_options_to_context(binding_context, binding_options) + + local kind = binding.kind + append_binding_spec(bindings, kind, { + id = binding.id, + kind = kind, + trait_id = trait_id, + contract = binding.contract, + binding = binding.binding, + phases = binding.phases or {}, + context = binding_context, + options = binding_options, + priority = binding.priority or 100, + strict = binding.strict == true, + order = 0, + }) + end +end + +local function append_behavior_tool_wrapper( + tool_wrappers: {ToolWrapperSpec}, + trait_id: string, + behavior: any, + handles: {[string]: boolean}, + base_context: table, + base_options: table +) + local binding = behavior_handler(behavior, "tool_wrapper") + if not binding then + return + end + + local phases = {} + for _, phase in ipairs(TOOL_WRAPPER_PHASE_ORDER) do + if handles[phase] then + phases[#phases + 1] = phase + end + end + if #phases == 0 then + return + end + + for _, phase in ipairs(phases) do + local options = merge_agent_options(base_options, type(behavior[phase]) == "table" and behavior[phase] or nil) + local context = deep_copy(base_context) + attach_options_to_context(context, options) + tool_wrappers[#tool_wrappers + 1] = { + id = behavior.id, + trait_id = trait_id, + phases = { phase }, + binding = binding, + context = context, + options = options, + priority = tonumber(behavior.priority) or 100, + strict = behavior.strict == true, + order = #tool_wrappers + 1, + } + end +end + +local function append_behavior_lifecycle( + bindings: {[string]: {BindingSpec}}, + trait_id: string, + behavior: any, + handles: {[string]: boolean}, + base_context: table, + base_options: table +) + local binding = behavior_handler(behavior, "lifecycle") + if not binding then + return + end + + local phases = {} + for _, phase in ipairs(LIFECYCLE_PHASE_ORDER) do + if handles[phase] then + phases[#phases + 1] = phase + end + end + if #phases == 0 then + return + end + + for _, phase in ipairs(phases) do + local options = merge_agent_options(base_options, behavior.lifecycle) + options = merge_agent_options(options, type(behavior[phase]) == "table" and behavior[phase] or nil) + + local context = deep_copy(base_context) + attach_options_to_context(context, options) + append_binding_spec(bindings, "lifecycle", { + id = behavior.id, + kind = "lifecycle", + trait_id = trait_id, + contract = "wippy.agent:lifecycle", + binding = binding, + phases = { phase }, + context = context, + options = options, + priority = tonumber(behavior.priority) or 100, + strict = behavior.strict == true, + order = 0, + }) + end +end + +local function checkpoint_agent_options(checkpoint_config: any): table + local out = {} + if type(checkpoint_config) ~= "table" then + return out + end + + local checkpoint = {} + for key, value in pairs(checkpoint_config) do + if CHECKPOINT_AGENT_OPTION_KEYS[key] then + checkpoint[key] = deep_copy(value) + end + end + + if next(checkpoint) ~= nil then + out.checkpoint = checkpoint + end + return out +end + +local function append_behavior_checkpoint( + bindings: {[string]: {BindingSpec}}, + trait_id: string, + behavior: any, + handles: {[string]: boolean}, + base_context: table, + base_options: table +): table + if not handles.checkpoint then + return {} + end + + local binding = behavior_handler(behavior, "checkpoint") + local checkpoint_config = type(behavior.checkpoint) == "table" and behavior.checkpoint or {} + local agent_options = checkpoint_agent_options(checkpoint_config) + + if not binding then + return agent_options + end + + local options = merge_agent_options(base_options, behavior.checkpoint) + attach_options_to_context(base_context, options) + append_binding_spec(bindings, "checkpoint", { + id = behavior.id, + kind = "checkpoint", + trait_id = trait_id, + contract = "wippy.agent:checkpoint", + binding = binding, + phases = { "checkpoint" }, + context = base_context, + options = options, + priority = tonumber(behavior.priority) or 100, + strict = behavior.strict == true, + order = 0, + }) + + return agent_options +end + +local function append_behaviors( + bindings: {[string]: {BindingSpec}}, + tool_wrappers: {ToolWrapperSpec}, + trait_id: string, + trait_def: any, + trait_context: table, + attachment_options: table, + raw_spec: any, + config: CompilerConfig +): table + local behaviors = behaviors_from_trait(trait_def) + local behavior_agent_options = {} + if #behaviors == 0 then + return behavior_agent_options + end + + local trait_options = type(trait_def.options) == "table" and trait_def.options or {} + + for _, behavior in ipairs(behaviors) do + local handles = set_from_list(list_from_map_or_array(behavior.handles)) + for phase, _ in pairs(LIFECYCLE_PHASES) do + if behavior[phase] == false then + handles[phase] = nil + end + end + for phase, _ in pairs(TOOL_WRAPPER_PHASES) do + if behavior[phase] == false then + handles[phase] = nil + end + end + if behavior.checkpoint == false then + handles.checkpoint = nil + end + + local behavior_context = config.context_merger( + trait_context or {}, + raw_spec.context or {}, + type(behavior.context) == "table" and behavior.context or {} + ) + behavior_context.agent_id = raw_spec.id + behavior_context.trait_id = trait_id + if type(behavior.id) == "string" and behavior.id ~= "" then + behavior_context.behavior_id = behavior.id + end + if type(behavior.kind) == "string" and behavior.kind ~= "" then + behavior_context.behavior_kind = behavior.kind + end + + local base_options = merge_agent_options(trait_options, behavior.options) + base_options = merge_agent_options(base_options, attachment_options) + + append_behavior_lifecycle( + bindings, + trait_id, + behavior, + handles, + deep_copy(behavior_context), + base_options + ) + + local checkpoint_options = append_behavior_checkpoint( + bindings, + trait_id, + behavior, + handles, + deep_copy(behavior_context), + base_options + ) + behavior_agent_options = merge_agent_options(behavior_agent_options, checkpoint_options) + + append_behavior_tool_wrapper( + tool_wrappers, + trait_id, + behavior, + handles, + deep_copy(behavior_context), + base_options + ) + end + + return behavior_agent_options +end + local function get_canonical_tool_name(tool_id: any, tool_def: any): any if type(tool_def) == "table" and tool_def.alias then return tool_def.alias @@ -153,7 +846,7 @@ local function get_canonical_tool_name(tool_id: any, tool_def: any): any return name end -local function process_traits(raw_spec: any, config: CompilerConfig): (any, any, any, any, any, any, any) +local function process_traits(raw_spec: any, config: CompilerConfig): (any, any, any, any, any, any, any, any, any, any) local additional_prompts = {} local additional_tools = {} local trait_contexts = {} @@ -161,14 +854,22 @@ local function process_traits(raw_spec: any, config: CompilerConfig): (any, any, local additional_delegates = {} local prompt_funcs = {} local step_funcs = {} + local tool_wrappers = {} + local bindings = {} + local agent_options = {} if not raw_spec.traits or #raw_spec.traits == 0 then - return additional_prompts, additional_tools, trait_contexts, trait_tool_schemas, additional_delegates, prompt_funcs, step_funcs + return additional_prompts, additional_tools, trait_contexts, trait_tool_schemas, additional_delegates, prompt_funcs, step_funcs, tool_wrappers, bindings, agent_options end for i, trait_config in ipairs(raw_spec.traits) do local trait_id = type(trait_config) == "string" and trait_config or trait_config.id local trait_instance_context = type(trait_config) == "table" and trait_config.context or {} + local trait_instance_options = type(trait_config) == "table" and trait_config.options or {} + + if type(trait_id) ~= "string" or trait_id == "" then + goto continue + end local trait_def, err = get_traits().get_by_id(trait_id) if not trait_def then @@ -214,6 +915,26 @@ local function process_traits(raw_spec: any, config: CompilerConfig): (any, any, }) end + local behavior_agent_options = append_behaviors( + bindings, + tool_wrappers, + trait_id, + trait_def, + trait_contexts[trait_id], + trait_instance_options, + raw_spec, + config + ) + agent_options = merge_agent_options(agent_options, behavior_agent_options) + + append_tool_wrappers(tool_wrappers, trait_id, trait_def, trait_contexts[trait_id], raw_spec, config) + append_bindings(bindings, trait_id, trait_def, trait_contexts[trait_id], trait_instance_options, raw_spec, config) + + agent_options = merge_agent_options(agent_options, trait_def.agent_options) + if type(trait_config) == "table" then + agent_options = merge_agent_options(agent_options, trait_config.agent_options) + end + if trait_def.build_func_id then local merged_context = config.context_merger( trait_contexts[trait_id], -- Use already merged trait context @@ -259,13 +980,37 @@ local function process_traits(raw_spec: any, config: CompilerConfig): (any, any, {} ) end + + if contribution.agent_options then + agent_options = merge_agent_options(agent_options, contribution.agent_options) + end end end ::continue:: end - return additional_prompts, additional_tools, trait_contexts, trait_tool_schemas, additional_delegates, prompt_funcs, step_funcs + table.sort(tool_wrappers, function(a, b) + local ap = a.priority or 100 + local bp = b.priority or 100 + if ap == bp then + return (a.order or 0) < (b.order or 0) + end + return ap < bp + end) + + for _, binding_list in pairs(bindings) do + table.sort(binding_list, function(a, b) + local ap = a.priority or 100 + local bp = b.priority or 100 + if ap == bp then + return (a.order or 0) < (b.order or 0) + end + return ap < bp + end) + end + + return additional_prompts, additional_tools, trait_contexts, trait_tool_schemas, additional_delegates, prompt_funcs, step_funcs, tool_wrappers, bindings, agent_options end local function process_tools(raw_spec: any, additional_tools: any, trait_contexts: any, trait_tool_schemas: any, config: CompilerConfig): any @@ -437,6 +1182,38 @@ local function process_delegates(raw_spec: any, additional_delegates: any, confi return delegate_tools, delegate_contexts end +local function append_memory_contract_binding(bindings: {[string]: {BindingSpec}}, raw_spec: any, config: CompilerConfig) + local memory_contract = raw_spec and raw_spec.memory_contract + if type(memory_contract) ~= "table" or type(memory_contract.implementation_id) ~= "string" or memory_contract.implementation_id == "" then + return + end + + local binding_context = config.context_merger( + {}, + raw_spec.context or {}, + type(memory_contract.context) == "table" and memory_contract.context or {} + ) + binding_context.agent_id = raw_spec.id + + local binding_options = type(memory_contract.options) == "table" and deep_copy(memory_contract.options) or {} + attach_options_to_context(binding_context, binding_options) + + append_binding_spec(bindings, "memory", { + id = "memory_contract", + kind = "memory", + trait_id = nil, + contract = "wippy.agent:memory", + binding = memory_contract.implementation_id, + phases = {}, + context = binding_context, + options = binding_options, + priority = 1000, + strict = false, + order = 0, + source = "memory_contract", + }) +end + function compiler.compile(raw_spec: table?, user_config: table?): (any, string?) if not raw_spec then return nil, "Raw spec is required" @@ -444,9 +1221,11 @@ function compiler.compile(raw_spec: table?, user_config: table?): (any, string?) local config = merge_config(user_config) - local additional_prompts, additional_tools, trait_contexts, trait_tool_schemas, additional_delegates, prompt_funcs, step_funcs = process_traits( + local additional_prompts, additional_tools, trait_contexts, trait_tool_schemas, additional_delegates, prompt_funcs, step_funcs, tool_wrappers, bindings, trait_agent_options = process_traits( raw_spec, config) + append_memory_contract_binding(bindings :: {[string]: {BindingSpec}}, raw_spec, config) + local tools = process_tools(raw_spec, additional_tools, trait_contexts, trait_tool_schemas, config) local delegate_tools, delegate_contexts = process_delegates(raw_spec, additional_delegates, config) @@ -477,8 +1256,11 @@ function compiler.compile(raw_spec: table?, user_config: table?): (any, string?) tools = tools, memory = raw_spec.memory or {}, memory_contract = raw_spec.memory_contract, + agent_options = merge_agent_options(trait_agent_options, raw_spec.agent_options), prompt_funcs = prompt_funcs, - step_funcs = step_funcs + step_funcs = step_funcs, + tool_wrappers = tool_wrappers, + bindings = bindings, } return compiled_spec diff --git a/src/agent/src/compiler/compiler_test.lua b/src/agent/src/compiler/compiler_test.lua index db7f522..7900562 100644 --- a/src/agent/src/compiler/compiler_test.lua +++ b/src/agent/src/compiler/compiler_test.lua @@ -315,6 +315,73 @@ local function define_tests() } } + local spec_with_tool_wrapper_trait = { + id = "test:tool_wrapper", + name = "Agent With Tool Wrapper Trait", + description = "Agent whose trait wraps tool execution", + model = "gpt-4o-mini", + prompt = "You are an agent with tool wrappers.", + context = { + agent_setting = "agent_value", + shared_setting = "agent_overrides_trait" + }, + agent_options = { + checkpoint = { + strict = true, + token_threshold = 96000 + } + }, + traits = { + { + id = "tool_wrapper_trait", + context = { + trait_instance_setting = "instance_value", + shared_setting = "instance_overrides_agent" + }, + agent_options = { + checkpoint = { + token_threshold = 16000, + function_id = "agent.checkpoint:instance" + } + } + }, + "ignored_runtime_provider_trait", + "invalid_tool_wrapper_trait" + } + } + + local spec_with_contract_traits = { + id = "test:contract_traits", + name = "Agent With Contract Traits", + description = "Agent whose traits own memory, lifecycle, and checkpoint bindings", + model = "gpt-4o-mini", + prompt = "You are an agent with trait-owned contracts.", + context = { + workspace = "/repo", + namespace = "agent_context", + shared_setting = "agent_context_wins" + }, + traits = { + { + id = "memory_contract_trait", + context = { + namespace = "ops", + shard = "primary", + shared_setting = "attachment_context" + }, + options = { + recall = { + max_items = 5 + }, + checkpoint = { + token_threshold = 32000 + } + } + }, + "audit_lifecycle_trait" + } + } + local trait_definitions = { static_trait = { id = "static_trait", @@ -392,6 +459,233 @@ local function define_tests() dynamic_prompts = true }, prompt_func_id = "generate_dynamic_prompt" + }, + tool_wrapper_trait = { + id = "tool_wrapper_trait", + name = "Tool Wrapper Trait", + description = "Trait with tool wrapper specs", + prompt = "", + tools = {}, + context = { + memory_profile = "default", + shared_setting = "trait_default" + }, + agent_options = { + checkpoint = { + token_threshold = 32000, + function_id = "agent.checkpoint:default", + max_memory_chars = 8192 + } + }, + tool_wrappers = { + { + id = "audit_after", + phase = "after_execute", + binding = "test.wrapper:audit", + priority = 20, + strict = true, + context = { + slot = "audit" + }, + options = { + include_results = true + } + }, + { + id = "guard_before", + phases = { "before_execute" }, + binding = "test.wrapper:guard", + priority = 10, + options = { + max_calls = 4 + } + } + } + }, + ignored_runtime_provider_trait = { + id = "ignored_runtime_provider_trait", + name = "Ignored Runtime Provider Trait", + description = "Trait with runtime and notify fields that must not map to tool wrappers", + prompt = "", + tools = {}, + context = {}, + runtime_hook = { + id = "ignored_checkpoint", + phase = "checkpoint", + binding_id = "test.runtime:ignored_provider", + options = { + token_threshold = 64000 + } + }, + notify_hook = { + id = "ignored_notify", + event = "turn.failed", + binding_id = "test.notify:ignored", + priority = 35, + options = { + severity = "error" + } + } + }, + invalid_tool_wrapper_trait = { + id = "invalid_tool_wrapper_trait", + name = "Invalid Tool Wrapper Trait", + description = "Trait with malformed tool wrapper specs", + prompt = "", + tools = {}, + context = {}, + tool_wrappers = { + { + id = "missing_target", + phase = "before_execute" + }, + { + id = "unsupported_phase", + phase = "around_execute", + binding = "test.wrapper:invalid" + } + } + }, + memory_contract_trait = { + id = "memory_contract_trait", + name = "Memory Contract Trait", + description = "Trait that owns memory, lifecycle, and checkpoint bindings", + prompt = "Use durable memory when relevant.", + tools = {}, + context = { + namespace = "default", + shard = "default", + shared_setting = "trait_default" + }, + options = { + recall = { + enabled = true, + max_items = 2, + max_length = 1000 + }, + checkpoint = { + enabled = true, + token_threshold = 24000, + max_memory_chars = 8192 + } + }, + bindings = { + memory = { + id = "memory", + contract = "wippy.agent:memory", + binding = "test.memory:store", + context = { + slot = "memory" + }, + options = { + recall = { + max_length = 2000 + } + } + }, + lifecycle = { + id = "memory_lifecycle", + contract = "wippy.agent:lifecycle", + binding = "test.memory:lifecycle", + phases = { "activate", "deactivate", "after_step" }, + priority = 30, + context = { + hook = "memory" + } + }, + checkpoint = { + id = "memory_checkpoint", + contract = "wippy.agent:checkpoint", + binding = "test.memory:checkpoint", + priority = 20, + context = { + mode = "memory" + } + } + } + }, + audit_lifecycle_trait = { + id = "audit_lifecycle_trait", + name = "Audit Lifecycle Trait", + description = "Second lifecycle binding for ordering tests", + prompt = "", + tools = {}, + context = {}, + options = { + audit = { + level = "standard" + } + }, + bindings = { + lifecycle = { + id = "audit_lifecycle", + contract = "wippy.agent:lifecycle", + binding = "test.audit:lifecycle", + phases = { "after_step" }, + priority = 10, + options = { + audit = { + include_tools = true + } + } + } + } + }, + behavior_trait = { + id = "behavior_trait", + name = "Behavior Trait", + description = "Trait that declares lifecycle, checkpoint, and wrapper behavior in one place", + prompt = "Use behavior-defined memory.", + tools = {}, + context = { + namespace = "default", + profile = "default" + }, + behaviors = { + { + id = "durable_memory", + kind = "memory", + priority = 10, + handles = { "activate", "checkpoint", "deactivate" }, + handlers = { + lifecycle = "test.behavior:lifecycle", + checkpoint = "test.behavior:checkpoint" + }, + context = { + kb_id = "kb-1" + }, + options = { + memory_scope = "agent" + }, + activate = { + recall_window = 3 + }, + checkpoint = { + token_threshold = 12000, + checkpoint_model = "class:fast", + checkpoint_max_tokens = 700, + max_memory_chars = 2000 + }, + deactivate = { + commit_selector = "since_checkpoint" + } + }, + { + id = "tool_audit", + kind = "tool_wrapper", + handles = { "before_execute", "after_execute" }, + handlers = { + tool_wrapper = "test.behavior:tool_wrapper" + }, + priority = 90, + before_execute = { + include_args = true + }, + after_execute = { + include_results = true + } + } + } } } @@ -747,18 +1041,20 @@ local function define_tests() end end) - it("should handle empty runtime function lists for agents without runtime traits", function() + it("should handle empty extension lists for agents without dynamic traits", function() local compiled_spec, err = compiler.compile(minimal_spec) test.is_nil(err) test.not_nil(compiled_spec) test.not_nil(compiled_spec.prompt_funcs) test.not_nil(compiled_spec.step_funcs) + test.not_nil(compiled_spec.tool_wrappers) test.eq(#compiled_spec.prompt_funcs, 0) test.eq(#compiled_spec.step_funcs, 0) + test.eq(#compiled_spec.tool_wrappers, 0) end) - it("should not include traits without runtime functions in runtime collections", function() + it("should not include traits without wrappers in wrapper collections", function() local spec_with_static_traits = { id = "test:static_only", name = "Static Traits Only", @@ -776,6 +1072,217 @@ local function define_tests() test.not_nil(compiled_spec) test.eq(#compiled_spec.prompt_funcs, 0) test.eq(#compiled_spec.step_funcs, 0) + test.eq(#compiled_spec.tool_wrappers, 0) + end) + + it("should collect trait-owned tool wrapper specs", function() + local compiled_spec, err = compiler.compile(spec_with_tool_wrapper_trait) + + test.is_nil(err) + test.not_nil(compiled_spec) + test.not_nil(compiled_spec.tool_wrappers) + test.eq(#compiled_spec.tool_wrappers, 2) + + local first = compiled_spec.tool_wrappers[1] + test.eq(first.id, "guard_before") + test.eq(first.trait_id, "tool_wrapper_trait") + test.eq(first.binding, "test.wrapper:guard") + test.eq(first.phases[1], "before_execute") + test.eq(first.priority, 10) + test.eq(first.options.max_calls, 4) + + local second = compiled_spec.tool_wrappers[2] + test.eq(second.id, "audit_after") + test.eq(second.trait_id, "tool_wrapper_trait") + test.eq(second.binding, "test.wrapper:audit") + test.eq(second.phases[1], "after_execute") + test.eq(second.priority, 20) + test.is_true(second.strict) + test.is_true(second.options.include_results) + end) + + it("should merge trait agent options with attachment and agent overrides", function() + local compiled_spec, err = compiler.compile(spec_with_tool_wrapper_trait) + + test.is_nil(err) + test.not_nil(compiled_spec) + test.not_nil(compiled_spec.agent_options) + + test.eq(compiled_spec.agent_options.checkpoint.token_threshold, 96000, + "agent-level checkpoint threshold wins last") + test.eq(compiled_spec.agent_options.checkpoint.function_id, "agent.checkpoint:instance", + "attachment checkpoint function is preserved") + test.eq(compiled_spec.agent_options.checkpoint.max_memory_chars, 8192) + test.is_true(compiled_spec.agent_options.checkpoint.strict, + "agent-level option overlays trait and attachment options") + end) + + it("should merge trait instance, agent, and wrapper context for tool wrappers", function() + local compiled_spec, err = compiler.compile(spec_with_tool_wrapper_trait) + + test.is_nil(err) + test.not_nil(compiled_spec) + + local wrapper = compiled_spec.tool_wrappers[2] + test.eq(wrapper.context.memory_profile, "default") + test.eq(wrapper.context.agent_setting, "agent_value") + test.eq(wrapper.context.shared_setting, "agent_overrides_trait") + test.eq(wrapper.context.trait_instance_setting, "instance_value") + test.eq(wrapper.context.slot, "audit") + test.eq(wrapper.context.agent_id, "test:tool_wrapper") + test.eq(wrapper.context.trait_id, "tool_wrapper_trait") + end) + + it("should ignore runtime/notify fields and malformed tool wrappers", function() + local compiled_spec, err = compiler.compile(spec_with_tool_wrapper_trait) + + test.is_nil(err) + test.not_nil(compiled_spec) + + test.eq(#compiled_spec.tool_wrappers, 2) + for _, wrapper in ipairs(compiled_spec.tool_wrappers) do + test.is_false(wrapper.id == "ignored_checkpoint") + test.is_false(wrapper.id == "ignored_notify") + test.is_false(wrapper.id == "missing_target") + test.is_false(wrapper.id == "unsupported_phase") + end + end) + + it("should normalize trait-owned contract bindings into a compiled binding plan", function() + local compiled_spec, err = compiler.compile(spec_with_contract_traits) + + test.is_nil(err) + test.not_nil(compiled_spec) + test.not_nil(compiled_spec.bindings) + + test.eq(#compiled_spec.bindings.memory, 1) + test.eq(#compiled_spec.bindings.lifecycle, 2) + test.eq(#compiled_spec.bindings.checkpoint, 1) + + local memory = compiled_spec.bindings.memory[1] + test.eq(memory.kind, "memory") + test.eq(memory.contract, "wippy.agent:memory") + test.eq(memory.binding, "test.memory:store") + test.eq(memory.context.agent_id, "test:contract_traits") + test.eq(memory.context.trait_id, "memory_contract_trait") + test.eq(memory.context.slot, "memory") + end) + + it("should merge trait defaults, binding options, and attachment options into binding context options", function() + local compiled_spec, err = compiler.compile(spec_with_contract_traits) + + test.is_nil(err) + test.not_nil(compiled_spec) + + local memory = compiled_spec.bindings.memory[1] + test.eq(memory.context.namespace, "agent_context", "agent context still follows existing context merge order") + test.eq(memory.context.shard, "primary") + test.eq(memory.context.shared_setting, "agent_context_wins") + test.is_true(memory.context.options.recall.enabled) + test.eq(memory.context.options.recall.max_items, 5, + "trait attachment options override trait defaults") + test.eq(memory.context.options.recall.max_length, 2000, + "binding-local options refine trait defaults") + test.eq(memory.context.options.checkpoint.token_threshold, 32000, + "attachment checkpoint option overrides trait default") + test.eq(memory.context.options.checkpoint.max_memory_chars, 8192) + test.eq(memory.options.recall.max_items, 5) + test.eq(memory.options.recall.max_length, 2000) + end) + + it("should order multiple bindings of the same kind by priority then declaration order", function() + local compiled_spec, err = compiler.compile(spec_with_contract_traits) + + test.is_nil(err) + test.not_nil(compiled_spec) + + local lifecycle = compiled_spec.bindings.lifecycle + test.eq(#lifecycle, 2) + test.eq(lifecycle[1].id, "audit_lifecycle") + test.eq(lifecycle[1].binding, "test.audit:lifecycle") + test.eq(lifecycle[1].phases[1], "after_step") + test.eq(lifecycle[1].context.options.audit.level, "standard") + test.is_true(lifecycle[1].context.options.audit.include_tools) + + test.eq(lifecycle[2].id, "memory_lifecycle") + test.eq(lifecycle[2].binding, "test.memory:lifecycle") + test.eq(lifecycle[2].phases[1], "activate") + test.eq(lifecycle[2].phases[2], "deactivate") + test.eq(lifecycle[2].phases[3], "after_step") + end) + + it("should let a trait provide checkpoint behavior through a checkpoint binding", function() + local compiled_spec, err = compiler.compile(spec_with_contract_traits) + + test.is_nil(err) + test.not_nil(compiled_spec) + + local checkpoint = compiled_spec.bindings.checkpoint[1] + test.eq(checkpoint.contract, "wippy.agent:checkpoint") + test.eq(checkpoint.binding, "test.memory:checkpoint") + test.eq(checkpoint.context.mode, "memory") + test.eq(checkpoint.context.options.checkpoint.enabled, true) + test.eq(checkpoint.context.options.checkpoint.token_threshold, 32000) + test.eq(checkpoint.context.options.checkpoint.max_memory_chars, 8192) + end) + + it("should expand trait behaviors into lifecycle, checkpoint, and wrapper runtime plans", function() + local compiled_spec, err = compiler.compile({ + id = "test:behavior", + prompt = "Base prompt.", + context = { + workspace = "agent-context" + }, + traits = { + { + id = "behavior_trait", + context = { + profile = "run-profile" + }, + options = { + checkpoint_temperature = 0.1 + }, + agent_options = { + checkpoint = { + token_threshold = 9000 + } + } + } + } + }) + + test.is_nil(err) + test.not_nil(compiled_spec) + + test.eq(#compiled_spec.bindings.lifecycle, 2) + local activate = compiled_spec.bindings.lifecycle[1] + test.eq(activate.contract, "wippy.agent:lifecycle") + test.eq(activate.binding, "test.behavior:lifecycle") + test.eq(activate.phases[1], "activate") + test.eq(activate.context.kb_id, "kb-1") + test.eq(activate.context.profile, "run-profile") + test.eq(activate.context.behavior_id, "durable_memory") + test.eq(activate.options.recall_window, 3) + + local deactivate = compiled_spec.bindings.lifecycle[2] + test.eq(deactivate.phases[1], "deactivate") + test.eq(deactivate.options.commit_selector, "since_checkpoint") + + local checkpoint = compiled_spec.bindings.checkpoint[1] + test.eq(checkpoint.contract, "wippy.agent:checkpoint") + test.eq(checkpoint.binding, "test.behavior:checkpoint") + test.eq(checkpoint.context.behavior_kind, "memory") + test.eq(checkpoint.options.checkpoint_model, "class:fast") + test.eq(checkpoint.options.checkpoint_temperature, 0.1) + test.eq(compiled_spec.agent_options.checkpoint.token_threshold, 9000) + test.eq(compiled_spec.agent_options.checkpoint.max_memory_chars, 2000) + + test.eq(#compiled_spec.tool_wrappers, 2) + test.eq(compiled_spec.tool_wrappers[1].binding, "test.behavior:tool_wrapper") + test.eq(compiled_spec.tool_wrappers[1].phases[1], "before_execute") + test.is_true(compiled_spec.tool_wrappers[1].options.include_args) + test.eq(compiled_spec.tool_wrappers[2].phases[1], "after_execute") + test.is_true(compiled_spec.tool_wrappers[2].options.include_results) end) end) @@ -1476,7 +1983,11 @@ local function define_tests() delegates = {}, memory_contract = { implementation_id = "memory:impl", - context = { setting = "value" } + context = { setting = "value" }, + options = { + max_items = 4, + max_length = 3000 + } } } @@ -1495,6 +2006,14 @@ local function define_tests() test.eq(compiled_spec.memory[1], "memory:item1") test.not_nil(compiled_spec.memory_contract) test.eq(compiled_spec.memory_contract.implementation_id, "memory:impl") + test.not_nil(compiled_spec.bindings) + test.eq(#compiled_spec.bindings.memory, 1) + test.eq(compiled_spec.bindings.memory[1].source, "memory_contract") + test.eq(compiled_spec.bindings.memory[1].contract, "wippy.agent:memory") + test.eq(compiled_spec.bindings.memory[1].binding, "memory:impl") + test.eq(compiled_spec.bindings.memory[1].context.setting, "value") + test.eq(compiled_spec.bindings.memory[1].context.options.max_items, 4) + test.eq(compiled_spec.bindings.memory[1].options.max_length, 3000) test.eq(compiled_spec.tools["tool"].context.agent_id, "test:full") end) @@ -1699,4 +2218,4 @@ local function define_tests() end) end -return require("test").run_cases(define_tests) \ No newline at end of file +return require("test").run_cases(define_tests) diff --git a/src/agent/src/context.lua b/src/agent/src/context.lua index 6671437..6f4211b 100644 --- a/src/agent/src/context.lua +++ b/src/agent/src/context.lua @@ -134,6 +134,46 @@ local function merge_contexts(base_context: {[string]: any}?, override_context: return merged end +local function copy_list(list: any): any + if type(list) ~= "table" then + return list + end + + local copied = {} + for i, value in ipairs(list) do + copied[i] = value + end + return copied +end + +local function copy_map(map: any): any + if type(map) ~= "table" then + return map + end + + local copied = {} + for key, value in pairs(map) do + copied[key] = value + end + return copied +end + +local function copy_agent_spec(raw_spec: table): table + local copied = {} + for key, value in pairs(raw_spec) do + copied[key] = value + end + + copied.traits = copy_list(raw_spec.traits) + copied.tools = copy_list(raw_spec.tools) + copied.delegates = copy_list(raw_spec.delegates) + copied.memory = copy_list(raw_spec.memory) + copied.context = copy_map(raw_spec.context) + copied.agent_options = copy_map(raw_spec.agent_options) + + return copied +end + function agent_context.new(config: { enable_cache: boolean?, context: {[string]: any}?, @@ -348,6 +388,8 @@ function agent_context:load_agent(agent_spec_or_id: string | table, options: Loa end end + raw_spec = copy_agent_spec(raw_spec) + if model_override then raw_spec.model = model_override end diff --git a/src/agent/src/context_test.lua b/src/agent/src/context_test.lua index 86b730d..e0e1cf8 100644 --- a/src/agent/src/context_test.lua +++ b/src/agent/src/context_test.lua @@ -960,6 +960,27 @@ local function define_tests() test.eq(#captured.traits, 0, "empty overlay yields no traits") end) + it("should not mutate caller-owned agent specs when overlays are applied", function() + capture_compiler() + local inline_agent = { + id = "inline-agent", + name = "Inline Agent", + model = "gpt-4o-mini", + prompt = "Inline.", + traits = { "base_trait" }, + tools = { "base:tool" } + } + + context:set_active_traits({ "overlay_trait" }) + context:set_active_tools({ "overlay:tool" }) + context:load_agent(inline_agent) + + test.eq(captured.traits[1], "overlay_trait") + test.eq(captured.tools[1], "overlay:tool") + test.eq(inline_agent.traits[1], "base_trait") + test.eq(inline_agent.tools[1], "base:tool") + end) + it("should chain overlay setters", function() local result = context:set_active_traits({ "t" }):set_active_tools({ "x" }) @@ -981,12 +1002,60 @@ local function define_tests() id = "trait:researcher", name = "researcher", prompt = "You research thoroughly before answering.", - tools = { "research:search" } + tools = { "research:search" }, + agent_options = { + compact = { + token_threshold = 24000, + function_id = "agent.compact:research" + }, + checkpoint = { + token_threshold = 48000 + } + }, + tool_wrappers = { + { + id = "research_guard", + phase = "before_execute", + binding = "test.wrapper:research_guard", + priority = 10, + context = { + lane = "research" + }, + options = { + max_calls = 4 + } + }, + { + id = "research_audit", + phase = "after_execute", + binding = "test.wrapper:research_audit", + priority = 20, + options = { + include_results = true + } + } + } }, ["trait:writer"] = { id = "trait:writer", name = "writer", - prompt = "You write concisely." + prompt = "You write concisely.", + agent_options = { + compact = { + token_threshold = 12000, + function_id = "agent.compact:writer" + } + }, + tool_wrapper = { + id = "writer_guard", + phase = "before_execute", + binding = "test.wrapper:writer_guard", + priority = 5, + options = { + style = "brief", + max_calls = 2 + } + } } } @@ -1150,6 +1219,120 @@ local function define_tests() end test.eq(count, 1, "duplicate tool id collapses to a single compiled entry") end) + + it("activates tool wrappers from the selected trait overlay", function() + use_real_compiler() + context:set_active_traits({ "trait:researcher" }) + context:load_agent("test-agent") + + test.eq(#compiled.tool_wrappers, 2) + test.eq(compiled.tool_wrappers[1].id, "research_guard") + test.eq(compiled.tool_wrappers[1].binding, "test.wrapper:research_guard") + test.eq(compiled.tool_wrappers[1].phases[1], "before_execute") + test.eq(compiled.tool_wrappers[1].context.lane, "research") + test.eq(compiled.tool_wrappers[1].context.agent_id, "test-agent") + test.eq(compiled.tool_wrappers[1].context.trait_id, "trait:researcher") + test.eq(compiled.tool_wrappers[1].options.max_calls, 4) + + test.eq(compiled.tool_wrappers[2].id, "research_audit") + test.eq(compiled.tool_wrappers[2].binding, "test.wrapper:research_audit") + test.eq(compiled.tool_wrappers[2].phases[1], "after_execute") + test.is_true(compiled.tool_wrappers[2].options.include_results) + end) + + it("activates agent options from the selected trait overlay", function() + use_real_compiler() + context:set_active_traits({ "trait:researcher" }) + context:load_agent("test-agent") + + test.not_nil(compiled.agent_options) + test.eq(compiled.agent_options.compact.token_threshold, 24000) + test.eq(compiled.agent_options.compact.function_id, "agent.compact:research") + test.eq(compiled.agent_options.checkpoint.token_threshold, 48000) + end) + + it("switches active traits by replacing tool wrapper sets", function() + use_real_compiler() + context:set_active_traits({ "trait:researcher" }) + context:load_agent("test-agent") + test.eq(compiled.tool_wrappers[1].binding, "test.wrapper:research_guard") + + context:set_active_traits({ "trait:writer" }) + context:load_agent("test-agent") + + test.eq(#compiled.tool_wrappers, 1) + test.eq(compiled.tool_wrappers[1].id, "writer_guard") + test.eq(compiled.tool_wrappers[1].binding, "test.wrapper:writer_guard") + test.eq(compiled.tool_wrappers[1].phases[1], "before_execute") + test.eq(compiled.tool_wrappers[1].options.style, "brief") + test.eq(compiled.tool_wrappers[1].options.max_calls, 2) + test.eq(compiled.agent_options.compact.token_threshold, 12000) + test.eq(compiled.agent_options.compact.function_id, "agent.compact:writer") + test.is_nil(compiled.agent_options.checkpoint) + end) + + it("deactivates trait-owned tool wrappers with an empty trait overlay", function() + use_real_compiler() + context:set_active_traits({ "trait:researcher" }) + context:load_agent("test-agent") + test.eq(#compiled.tool_wrappers, 2) + + context:set_active_traits({}) + context:load_agent("test-agent") + + test.eq(#compiled.tool_wrappers, 0) + test.is_nil(compiled.agent_options.compact) + end) + + it("restores the agent's own trait wrappers when the overlay is cleared", function() + use_real_compiler() + local inline_agent = { + id = "inline-agent", + name = "Inline Agent", + model = "gpt-4o-mini", + prompt = "Inline.", + traits = { "trait:researcher" } + } + + context:set_active_traits({ "trait:writer" }) + context:load_agent(inline_agent) + test.eq(compiled.tool_wrappers[1].binding, "test.wrapper:writer_guard") + + context:set_active_traits(nil) + context:load_agent(inline_agent) + test.eq(compiled.tool_wrappers[1].binding, "test.wrapper:research_guard") + test.eq(compiled.tool_wrappers[2].binding, "test.wrapper:research_audit") + test.eq(compiled.agent_options.compact.function_id, "agent.compact:research") + end) + + it("preserves trait-owned wrapper overlays across model switches on the same agent", function() + use_real_compiler() + context:set_active_traits({ "trait:researcher" }) + context:load_agent("test-agent") + + local ok, err = context:switch_to_model("claude-haiku") + + test.is_true(ok, tostring(err)) + test.eq(compiled.model, "claude-haiku") + test.eq(#compiled.tool_wrappers, 2) + test.eq(compiled.tool_wrappers[1].binding, "test.wrapper:research_guard") + test.eq(compiled.tool_wrappers[2].binding, "test.wrapper:research_audit") + test.eq(compiled.agent_options.compact.token_threshold, 24000) + end) + + it("drops trait-owned wrapper overlays when switching to a different agent", function() + use_real_compiler() + context:set_active_traits({ "trait:researcher" }) + context:load_agent("test-agent") + test.eq(#compiled.tool_wrappers, 2) + + local ok, err = context:switch_to_agent("specialist-agent") + + test.is_true(ok, tostring(err)) + test.eq(context.current_agent_id, "specialist-agent") + test.eq(#compiled.tool_wrappers, 0) + test.is_nil(compiled.agent_options.compact) + end) end) end) end diff --git a/src/agent/src/discovery/registry.lua b/src/agent/src/discovery/registry.lua index 54d5fbc..d4bfc5e 100644 --- a/src/agent/src/discovery/registry.lua +++ b/src/agent/src/discovery/registry.lua @@ -20,6 +20,7 @@ type RegistryEntry = { memory: {any}?, delegates: {any}?, memory_contract: any?, + agent_options: any?, context: {[string]: any}?, start_prompts: {any}?, }, @@ -41,6 +42,7 @@ type RawAgentSpec = { memory: {any}, delegates: {any}, memory_contract: any?, + agent_options: any?, context: {[string]: any}, start_prompts: {any}, } @@ -94,6 +96,7 @@ local function entry_to_raw_spec(entry: any): any memory = entry.data.memory or {}, delegates = entry.data.delegates or {}, memory_contract = entry.data.memory_contract, + agent_options = entry.data.agent_options, context = entry.data.context or {}, start_prompts = entry.data.start_prompts or {}, } diff --git a/src/agent/src/discovery/traits.lua b/src/agent/src/discovery/traits.lua index 998a501..38f87d0 100644 --- a/src/agent/src/discovery/traits.lua +++ b/src/agent/src/discovery/traits.lua @@ -14,6 +14,19 @@ type TraitToolEntry = { alias: string?, } +type TraitToolWrapperSpec = { + id: string?, + phases: {string}, + binding: string, + context: {[string]: any}, + options: {[string]: any}, + priority: number, + strict: boolean?, +} + +type TraitAgentOptions = {[string]: any} +type TraitOptions = {[string]: any} + type TraitSpec = { id: string, name: string, @@ -23,6 +36,10 @@ type TraitSpec = { build_func_id: string?, prompt_func_id: string?, step_func_id: string?, + bindings: any?, + tool_wrappers: {TraitToolWrapperSpec}, + options: TraitOptions, + agent_options: TraitAgentOptions, context: {[string]: any}, } @@ -39,6 +56,12 @@ type TraitRegistryEntry = { build_func_id: string?, prompt_func_id: string?, step_func_id: string?, + tool_wrapper: any?, + tool_wrappers: any?, + binding: any?, + bindings: any?, + options: TraitOptions?, + agent_options: TraitAgentOptions?, context: {[string]: any}?, }?, } @@ -118,6 +141,118 @@ local function process_tools(tools_data: any): {TraitToolEntry} return processed_tools :: {TraitToolEntry} end +local TOOL_WRAPPER_PHASES = { + before_execute = true, + after_execute = true, +} + +local function normalize_tool_wrapper_phases(raw_wrapper: any): {string} + local phases: {string} = {} + local raw_phases = raw_wrapper and (raw_wrapper.phases or raw_wrapper.phase) + + if raw_phases == nil then + return { "before_execute", "after_execute" } + end + + if type(raw_phases) == "string" then + if TOOL_WRAPPER_PHASES[raw_phases] then + phases[#phases + 1] = raw_phases + end + return phases :: {string} + end + + if type(raw_phases) == "table" then + for _, phase in ipairs(raw_phases) do + if type(phase) == "string" and TOOL_WRAPPER_PHASES[phase] then + phases[#phases + 1] = phase + end + end + end + + return phases :: {string} +end + +local function append_tool_wrapper(out: {TraitToolWrapperSpec}, raw_wrapper: any) + if type(raw_wrapper) ~= "table" then + return + end + + local binding = raw_wrapper.binding or raw_wrapper.binding_id or raw_wrapper.implementation_id + if type(binding) ~= "string" or binding == "" then + return + end + + local phases = normalize_tool_wrapper_phases(raw_wrapper) + if #phases == 0 then + return + end + + table.insert(out, { + id = raw_wrapper.id or raw_wrapper.name, + phases = phases, + binding = binding, + context = type(raw_wrapper.context) == "table" and raw_wrapper.context or {}, + options = type(raw_wrapper.options) == "table" and raw_wrapper.options or {}, + priority = tonumber(raw_wrapper.priority) or 100, + strict = raw_wrapper.strict == true, + }) +end + +local function collect_tool_wrappers(out: {TraitToolWrapperSpec}, raw_wrappers: any) + if type(raw_wrappers) ~= "table" then + return + end + + if raw_wrappers[1] ~= nil then + for _, raw_wrapper in ipairs(raw_wrappers) do + append_tool_wrapper(out, raw_wrapper) + end + return + end + + append_tool_wrapper(out, raw_wrappers) +end + +local function process_tool_wrappers(data: any): {TraitToolWrapperSpec} + local out: {TraitToolWrapperSpec} = {} + if type(data) ~= "table" then + return out + end + + collect_tool_wrappers(out, data.tool_wrappers) + collect_tool_wrappers(out, data.tool_wrapper) + + table.sort(out, function(a, b) + return (a.priority or 100) < (b.priority or 100) + end) + + return out +end + +local function process_agent_options(data: any): TraitAgentOptions + if type(data) ~= "table" or type(data.agent_options) ~= "table" then + return {} + end + + return data.agent_options :: TraitAgentOptions +end + +local function process_options(data: any): TraitOptions + if type(data) ~= "table" or type(data.options) ~= "table" then + return {} + end + + return data.options :: TraitOptions +end + +local function process_bindings(data: any): any + if type(data) ~= "table" then + return nil + end + + return data.bindings or data.binding +end + --------------------------- -- Trait Discovery Functions --------------------------- @@ -144,6 +279,10 @@ function traits.get_by_id(trait_id: string): (TraitSpec?, string?) -- Process tools to handle both formats local data = entry.data :: any local processed_tools = process_tools(data and data.tools) + local tool_wrappers = process_tool_wrappers(data) + local bindings = process_bindings(data) + local options = process_options(data) + local agent_options = process_agent_options(data) -- Build trait spec local trait_spec = { @@ -155,6 +294,10 @@ function traits.get_by_id(trait_id: string): (TraitSpec?, string?) build_func_id = (data and data.build_func_id), prompt_func_id = (data and data.prompt_func_id), step_func_id = (data and data.step_func_id), + bindings = bindings, + tool_wrappers = tool_wrappers, + options = options, + agent_options = agent_options, context = (data and data.context) or {}, } @@ -183,6 +326,10 @@ function traits.get_by_name(name: string): (TraitSpec?, string?) local entry = entries[1] local data = entry.data :: any local processed_tools = process_tools(data and data.tools) + local tool_wrappers = process_tool_wrappers(data) + local bindings = process_bindings(data) + local options = process_options(data) + local agent_options = process_agent_options(data) local trait_spec = { id = entry.id, @@ -193,6 +340,10 @@ function traits.get_by_name(name: string): (TraitSpec?, string?) build_func_id = (data and data.build_func_id), prompt_func_id = (data and data.prompt_func_id), step_func_id = (data and data.step_func_id), + bindings = bindings, + tool_wrappers = tool_wrappers, + options = options, + agent_options = agent_options, context = (data and data.context) or {}, } @@ -219,6 +370,10 @@ function traits.get_all(): {TraitSpec} for i, entry in ipairs(entries) do local data = entry.data :: any local processed_tools = process_tools(data and data.tools) + local tool_wrappers = process_tool_wrappers(data) + local bindings = process_bindings(data) + local options = process_options(data) + local agent_options = process_agent_options(data) local trait_spec = { id = entry.id, @@ -229,6 +384,10 @@ function traits.get_all(): {TraitSpec} build_func_id = (data and data.build_func_id), prompt_func_id = (data and data.prompt_func_id), step_func_id = (data and data.step_func_id), + bindings = bindings, + tool_wrappers = tool_wrappers, + options = options, + agent_options = agent_options, context = (data and data.context) or {}, } @@ -238,4 +397,4 @@ function traits.get_all(): {TraitSpec} return trait_specs :: {TraitSpec} end -return traits \ No newline at end of file +return traits diff --git a/src/agent/src/discovery/traits_test.lua b/src/agent/src/discovery/traits_test.lua index f41746c..acd6d82 100644 --- a/src/agent/src/discovery/traits_test.lua +++ b/src/agent/src/discovery/traits_test.lua @@ -161,6 +161,114 @@ local function define_tests() } }, + -- Tool wrapper trait with focused contract implementations + ["wippy.agents:tool_policy_trait"] = { + id = "wippy.agents:tool_policy_trait", + kind = "registry.entry", + meta = { + type = "agent.trait", + name = "Tool Policy", + comment = "Trait that wraps agent tool execution." + }, + data = { + prompt = "Review tool calls before executing them.", + tool_wrapper = { + id = "guard_before", + phase = "before_execute", + binding = "wippy.tool_policy:guard", + priority = 10, + strict = true, + context = { + policy = "guard" + }, + options = { + max_calls = 4 + } + }, + tool_wrappers = { + { + id = "audit_after", + phase = "after_execute", + binding = "wippy.tool_policy:audit", + priority = 20, + options = { + include_results = true + } + }, + { + id = "invalid_no_target", + phase = "before_execute" + }, + { + id = "invalid_phase", + phase = "around_execute", + binding = "wippy.tool_policy:invalid" + } + }, + context = { + policy_profile = "default" + }, + agent_options = { + compact = { + token_threshold = 24000, + max_memory_chars = 4096 + }, + checkpoint = { + token_threshold = 48000 + } + } + } + }, + + ["wippy.agents:legacy_runtime_memory_trait"] = { + id = "wippy.agents:legacy_runtime_memory_trait", + kind = "registry.entry", + meta = { + type = "agent.trait", + name = "Legacy Runtime Memory", + comment = "Old lifecycle fields that should not become tool wrappers." + }, + data = { + runtime_hook = { + id = "compact_context", + phase = "checkpoint", + binding_id = "wippy.memory:runtime_provider" + }, + notify_hook = { + id = "memory_commit", + event = "turn.completed", + binding_id = "wippy.memory:notify_provider" + }, + notify = { + hooks = { + { + id = "render_progress", + event = "turn.delta", + binding = "wippy.memory:notify_provider", + priority = 20, + context = { + channel = "renderer" + }, + options = { + include_payload = false + } + }, + { + id = "memory_commit", + events = { "turn.completed", "checkpoint.completed" }, + binding = "wippy.memory:notify_provider", + priority = 40, + strict = true, + options = { + persist = true + } + } + } + }, + context = {} + } + }, + -- Non-trait entry for validation ["wippy.agents:non_trait_entry"] = { id = "wippy.agents:non_trait_entry", @@ -240,6 +348,8 @@ local function define_tests() test.is_nil(trait.step_func_id) -- No step function test.not_nil(trait.context) test.is_nil(next(trait.context)) -- Empty context + test.not_nil(trait.tool_wrappers) + test.eq(#trait.tool_wrappers, 0) end) it("should get trait with old format tools", function() @@ -350,6 +460,45 @@ local function define_tests() test.eq(trait.context.timezone, "UTC") end) + it("should get trait with focused tool wrapper specs", function() + local trait, err = traits.get_by_id("wippy.agents:tool_policy_trait") + + test.is_nil(err) + test.not_nil(trait) + test.eq(trait.id, "wippy.agents:tool_policy_trait") + test.eq(trait.context.policy_profile, "default") + test.eq(trait.agent_options.compact.token_threshold, 24000) + test.eq(trait.agent_options.compact.max_memory_chars, 4096) + test.eq(trait.agent_options.checkpoint.token_threshold, 48000) + test.not_nil(trait.tool_wrappers) + test.eq(#trait.tool_wrappers, 2) + + local guard = trait.tool_wrappers[1] + test.eq(guard.id, "guard_before") + test.eq(guard.binding, "wippy.tool_policy:guard") + test.eq(guard.phases[1], "before_execute") + test.eq(guard.priority, 10) + test.is_true(guard.strict) + test.eq(guard.context.policy, "guard") + test.eq(guard.options.max_calls, 4) + + local audit = trait.tool_wrappers[2] + test.eq(audit.id, "audit_after") + test.eq(audit.binding, "wippy.tool_policy:audit") + test.eq(audit.phases[1], "after_execute") + test.eq(audit.priority, 20) + test.is_true(audit.options.include_results) + end) + + it("should not map old runtime and notify fields to tool wrappers", function() + local trait, err = traits.get_by_id("wippy.agents:legacy_runtime_memory_trait") + + test.is_nil(err) + test.not_nil(trait) + test.not_nil(trait.tool_wrappers) + test.eq(#trait.tool_wrappers, 0) + end) + it("should handle trait not found by ID", function() local trait, err = traits.get_by_id("nonexistent") @@ -382,7 +531,7 @@ local function define_tests() local all_traits = traits.get_all() -- Should find all valid traits (excluding non-trait entry) - test.eq(#all_traits, 7) + test.eq(#all_traits, 9) -- Check that all traits have required fields for _, trait in ipairs(all_traits) do @@ -400,6 +549,8 @@ local function define_tests() if trait.step_func_id then test.eq(type(trait.step_func_id), "string") end + test.not_nil(trait.tool_wrappers) + test.not_nil(trait.agent_options) end end) @@ -491,4 +642,4 @@ local function define_tests() end) end -return require("test").run_cases(define_tests) \ No newline at end of file +return require("test").run_cases(define_tests) diff --git a/src/agent/src/lifecycle_runtime.lua b/src/agent/src/lifecycle_runtime.lua new file mode 100644 index 0000000..d6dfaab --- /dev/null +++ b/src/agent/src/lifecycle_runtime.lua @@ -0,0 +1,200 @@ +local contract = require("contract") + +type LifecycleBindingSpec = { + id: string?, + trait_id: string?, + kind: string?, + contract: string?, + binding: string?, + phases: {string}?, + context: table?, + options: table?, + priority: number?, + strict: boolean?, + order: number?, +} + +type LifecyclePayload = { + phase: string, + host: table, + agent: table?, + reason: string?, + outcome: table?, + refs: table?, + run_context: table?, + options: table?, +} + +local PHASE = { + ACTIVATE = "activate", + BEFORE_STEP = "before_step", + AFTER_STEP = "after_step", + DEACTIVATE = "deactivate", +} + +local lifecycle_runtime = { + PHASE = PHASE, + CONTRACT_ID = "wippy.agent:lifecycle", +} + +lifecycle_runtime._contract = nil + +local function get_contract(): any + return lifecycle_runtime._contract or contract +end + +local function normalize_bindings(bindings: any): {LifecycleBindingSpec} + local raw = bindings + if type(raw) == "table" and type(raw.lifecycle) == "table" then + raw = raw.lifecycle + end + + local normalized: {LifecycleBindingSpec} = {} + for _, binding in ipairs(raw or {}) do + if type(binding) == "table" then + normalized[#normalized + 1] = binding + end + end + + table.sort(normalized, function(a, b) + local ap = tonumber(a.priority) or 100 + local bp = tonumber(b.priority) or 100 + if ap == bp then + return (tonumber(a.order) or 0) < (tonumber(b.order) or 0) + end + return ap < bp + end) + + return normalized :: {LifecycleBindingSpec} +end + +local function supports_phase(binding: LifecycleBindingSpec, phase: string): boolean + if type(binding.phases) ~= "table" or #binding.phases == 0 then + return true + end + + for _, binding_phase in ipairs(binding.phases) do + if binding_phase == phase then + return true + end + end + + return false +end + +local function copy_payload(payload: LifecyclePayload?): LifecyclePayload + local out = {} + for k, v in pairs(payload or {}) do + out[k] = v + end + return out :: LifecyclePayload +end + +local function merge_context(target: table, source: any) + if type(source) ~= "table" then + return + end + for k, v in pairs(source) do + target[k] = v + end +end + +local function call_binding(binding_spec: LifecycleBindingSpec, payload: LifecyclePayload): (table?, string?) + local binding = binding_spec.binding + if type(binding) ~= "string" or binding == "" then + return nil, "lifecycle binding is required" + end + + local contract_def, contract_err = get_contract().get(lifecycle_runtime.CONTRACT_ID) + if contract_err or not contract_def then + return nil, "failed to load lifecycle contract: " .. tostring(contract_err or "not found") + end + + local opener = contract_def + if opener.with_context then + opener = opener:with_context(binding_spec.context or {}) + end + + local instance, open_err = opener:open(tostring(binding)) + if open_err or not instance then + return nil, "failed to open lifecycle binding: " .. tostring(open_err or "no instance") + end + + local result, apply_err = instance:apply(payload) + if apply_err then + return nil, tostring(apply_err) + end + + return (type(result) == "table" and result or {}) :: table, nil +end + +function lifecycle_runtime.apply(bindings: any, payload: any): (table, string?) + local current_payload: LifecyclePayload = copy_payload(payload :: LifecyclePayload?) + local phase = current_payload.phase + local normalized = normalize_bindings(bindings) + + local summary = { + applied = 0, + skipped = 0, + messages = {}, + context = {}, + observations = {}, + metadata = {}, + errors = {}, + } + + if #normalized == 0 then + return summary, nil + end + + if type(phase) ~= "string" or phase == "" then + return summary, "lifecycle phase is required" + end + + if type(current_payload.host) ~= "table" or type(current_payload.host.kind) ~= "string" or current_payload.host.kind == "" then + return summary, "lifecycle host is required" + end + + for _, binding in ipairs(normalized) do + if supports_phase(binding, phase) then + current_payload.options = binding.options or {} + local result, err = call_binding(binding, current_payload :: LifecyclePayload) + if err then + local lifecycle_error = { + binding_id = binding.id, + trait_id = binding.trait_id, + phase = phase, + error = tostring(err), + strict = binding.strict == true, + } + summary.errors[#summary.errors + 1] = lifecycle_error + if binding.strict == true then + return summary, tostring(err) + end + else + summary.applied = summary.applied + 1 + for _, message in ipairs(result.messages or {}) do + summary.messages[#summary.messages + 1] = message + end + for _, observation in ipairs(result.observations or {}) do + summary.observations[#summary.observations + 1] = observation + end + merge_context(summary.context, result.context) + if result.metadata ~= nil then + summary.metadata[#summary.metadata + 1] = { + binding_id = binding.id, + trait_id = binding.trait_id, + phase = phase, + metadata = result.metadata, + } + end + end + else + summary.skipped = summary.skipped + 1 + end + end + + return summary, nil +end + +return lifecycle_runtime diff --git a/src/agent/src/lifecycle_runtime_test.lua b/src/agent/src/lifecycle_runtime_test.lua new file mode 100644 index 0000000..f66d29b --- /dev/null +++ b/src/agent/src/lifecycle_runtime_test.lua @@ -0,0 +1,263 @@ +local function define_tests() + describe("Agent lifecycle runtime", function() + local lifecycle_runtime + local calls + local behaviors + + before_each(function() + lifecycle_runtime = require("lifecycle_runtime") + calls = {} + behaviors = {} + + lifecycle_runtime._contract = { + get = function(contract_id) + if contract_id ~= "wippy.agent:lifecycle" then + return nil, "unexpected contract: " .. tostring(contract_id) + end + + local function opener_for(binding_context) + return { + open = function(_, binding_id) + return { + apply = function(_, payload) + calls[#calls + 1] = { + binding = binding_id, + context = binding_context or {}, + payload = payload, + } + + local behavior = behaviors[binding_id] + if behavior then + return behavior(payload, binding_context or {}) + end + return {}, nil + end + }, nil + end + } + end + + return { + with_context = function(_, binding_context) + return opener_for(binding_context) + end, + open = function(_, binding_id) + return opener_for({}):open(binding_id) + end, + }, nil + end + } + end) + + after_each(function() + lifecycle_runtime._contract = nil + lifecycle_runtime = nil + calls = nil + behaviors = nil + end) + + local function payload(phase) + return { + phase = phase, + host = { + kind = "session", + session_id = "s1" + }, + agent = { + id = "agent1", + model = "model1" + }, + run_context = { + contract = "wippy.agent:run_context", + binding = "host.run_context:binding", + host = { + kind = "session", + session_id = "s1" + } + } + } + end + + it("is a no-op when no lifecycle bindings are configured", function() + local result, err = lifecycle_runtime.apply(nil, payload("activate")) + + test.is_nil(err) + test.eq(result.applied, 0) + test.eq(result.skipped, 0) + test.eq(#calls, 0) + end) + + it("filters handlers by phase and keeps priority order", function() + local bindings = { + { + id = "late", + binding = "late", + phases = { "activate" }, + priority = 20, + }, + { + id = "skip", + binding = "skip", + phases = { "deactivate" }, + priority = 1, + }, + { + id = "early", + binding = "early", + phases = { "activate" }, + priority = 5, + }, + } + + local result, err = lifecycle_runtime.apply(bindings, payload("activate")) + + test.is_nil(err) + test.eq(result.applied, 2) + test.eq(result.skipped, 1) + test.eq(#calls, 2) + test.eq(calls[1].binding, "early") + test.eq(calls[2].binding, "late") + end) + + it("treats an empty phase list as all lifecycle phases", function() + local result, err = lifecycle_runtime.apply({ + { + id = "generic", + binding = "generic", + phases = {}, + } + }, payload("after_step")) + + test.is_nil(err) + test.eq(result.applied, 1) + test.eq(#calls, 1) + test.eq(calls[1].payload.phase, "after_step") + end) + + it("passes binding context, options, host, agent and run_context to handlers", function() + behaviors.inspect = function(in_payload, binding_context) + return { + context = { + phase_seen = in_payload.phase, + namespace = binding_context.namespace, + max_items = in_payload.options.max_items, + }, + observations = { + { + level = "info", + code = "seen", + content = in_payload.run_context.binding, + } + }, + metadata = { + host_kind = in_payload.host.kind, + agent_id = in_payload.agent.id, + }, + messages = { + { + role = "developer", + content = "memory activated" + } + } + }, nil + end + + local result, err = lifecycle_runtime.apply({ + { + id = "inspect", + trait_id = "trait.memory", + binding = "inspect", + context = { + namespace = "ops" + }, + options = { + max_items = 5 + }, + } + }, payload("activate")) + + test.is_nil(err) + test.eq(result.applied, 1) + test.eq(calls[1].context.namespace, "ops") + test.eq(calls[1].payload.options.max_items, 5) + test.eq(result.context.phase_seen, "activate") + test.eq(result.context.namespace, "ops") + test.eq(result.context.max_items, 5) + test.eq(result.observations[1].content, "host.run_context:binding") + test.eq(result.metadata[1].trait_id, "trait.memory") + test.eq(result.metadata[1].metadata.agent_id, "agent1") + test.eq(result.messages[1].content, "memory activated") + end) + + it("records non-strict handler errors and continues", function() + behaviors.bad = function() + return nil, "temporary failure" + end + behaviors.good = function() + return { + metadata = { + ok = true + } + }, nil + end + + local result, err = lifecycle_runtime.apply({ + { + id = "bad", + binding = "bad", + }, + { + id = "good", + binding = "good", + }, + }, payload("before_step")) + + test.is_nil(err) + test.eq(result.applied, 1) + test.eq(#result.errors, 1) + test.eq(result.errors[1].binding_id, "bad") + test.eq(result.errors[1].strict, false) + test.eq(result.metadata[1].metadata.ok, true) + end) + + it("fails on strict handler errors", function() + behaviors.strict_bad = function() + return nil, "strict failure" + end + + local result, err = lifecycle_runtime.apply({ + { + id = "strict_bad", + binding = "strict_bad", + strict = true, + } + }, payload("deactivate")) + + test.eq(err, "strict failure") + test.eq(result.applied, 0) + test.eq(#result.errors, 1) + test.eq(result.errors[1].strict, true) + end) + + it("rejects configured handlers when the host ref is missing", function() + local result, err = lifecycle_runtime.apply({ + { + id = "handler", + binding = "handler", + } + }, { + phase = "activate" + }) + + test.eq(err, "lifecycle host is required") + test.eq(result.applied, 0) + test.eq(#calls, 0) + end) + end) +end + +return { + run_tests = function() + return require("test").run_cases(define_tests) + end +} diff --git a/src/agent/src/tools/_index.yaml b/src/agent/src/tools/_index.yaml index 63989f8..1042110 100644 --- a/src/agent/src/tools/_index.yaml +++ b/src/agent/src/tools/_index.yaml @@ -15,6 +15,7 @@ entries: modules: - json - funcs + - contract imports: tools: wippy.agent.discovery:tools @@ -35,6 +36,7 @@ entries: source: file://caller_test.lua modules: - env + - time imports: test: wippy.test:test tool_caller: wippy.agent.tools:caller @@ -98,4 +100,3 @@ entries: - time - ctx method: delayed_echo - \ No newline at end of file diff --git a/src/agent/src/tools/caller.lua b/src/agent/src/tools/caller.lua index c5afeb3..aaeb2d0 100644 --- a/src/agent/src/tools/caller.lua +++ b/src/agent/src/tools/caller.lua @@ -1,6 +1,7 @@ local json = require("json") local tools = require("tools") local funcs = require("funcs") +local contract = require("contract") type ToolCall = { id: string, @@ -28,6 +29,86 @@ type ToolExecResult = { tool_call: ValidatedTool, } +type ToolWrapperPhase = string + +type ToolWrapperSpec = { + id: string?, + trait_id: string?, + phases: {ToolWrapperPhase}, + binding: string, + context: table?, + options: table?, + priority: number?, + strict: boolean?, + order: number?, +} + +type ToolWrapperHostRef = { + kind: string, + session_id: string?, + dataflow_id: string?, + node_id: string?, + run_id: string?, + iteration: number?, +} + +type ToolWrapperAgentRef = { + id: string?, + model: string?, +} + +type ToolWrapperOutcome = { + state: string, + reason: string, +} + +type ToolWrapperExecutionContext = { + host: ToolWrapperHostRef?, + agent: ToolWrapperAgentRef?, + outcome: ToolWrapperOutcome?, + run_context: any?, +} + +type ToolWrapperApplyRequest = { + phase: ToolWrapperPhase, + host: ToolWrapperHostRef?, + agent: ToolWrapperAgentRef?, + tool_calls: {ToolCall}, + tool_results: {[string]: ToolExecResult}?, + outcome: ToolWrapperOutcome?, + options: table, + run_context: any?, +} + +type ToolWrapperObservation = { + level: string, + code: string, + content: any, + metadata: table?, +} + +type ToolWrapperApplyResponse = { + skipped: boolean?, + tool_calls: {ToolCall}?, + observations: {ToolWrapperObservation}?, + metadata: table?, +} + +type ToolWrapperError = { + wrapper_id: string?, + trait_id: string?, + phase: ToolWrapperPhase, + error: string, + strict: boolean, +} + +type ToolWrapperMetadata = { + wrapper_id: string?, + trait_id: string?, + phase: ToolWrapperPhase, + metadata: table, +} + local FUNC_STATUS = { PENDING = "pending", SUCCESS = "success", @@ -39,12 +120,40 @@ local STRATEGY = { PARALLEL = "parallel" } +local BEFORE_EXECUTE: ToolWrapperPhase = "before_execute" +local AFTER_EXECUTE: ToolWrapperPhase = "after_execute" + +local PHASE = { + BEFORE_EXECUTE = BEFORE_EXECUTE, + AFTER_EXECUTE = AFTER_EXECUTE +} + +local OUTCOME_STATE = { + CONTINUES = "continues", + COMPLETED = "completed", + FAILED = "failed", + COMPACTED = "compacted", + DELEGATED = "delegated" +} + +local OUTCOME_REASON = { + TOOL_RESULTS_RECORDED = "tool_results_recorded", + TOOL_EXECUTION_FAILED = "tool_execution_failed", + EXIT_TOOL_COMPLETED = "exit_tool_completed", + DELEGATION_REQUESTED = "delegation_requested", + NO_TOOLS_REQUIRED = "no_tools_required", + MAX_ITERATIONS_REACHED = "max_iterations_reached", + CONTEXT_LIMIT_REACHED = "context_limit_reached", + HOST_FAILED = "host_failed" +} + local tool_caller = {} tool_caller.__index = tool_caller tool_caller._json = nil tool_caller._tools = nil tool_caller._funcs = nil +tool_caller._contract = nil local function get_json(): any return tool_caller._json or json @@ -58,10 +167,20 @@ local function get_funcs(): any return tool_caller._funcs or funcs end +local function get_contract(): any + return tool_caller._contract or contract +end + function tool_caller.new(): any local self = setmetatable({}, tool_caller) self.executor = get_funcs().new() self.strategy = STRATEGY.SEQUENTIAL -- Default strategy + self.tool_wrappers = {} + self.wrapper_context = {} + self.wrapper_observations = {} + self.wrapper_metadata = {} + self.wrapper_errors = {} + self.last_tool_calls = {} return self end @@ -72,12 +191,220 @@ function tool_caller:set_strategy(strategy: string): any return self end +local function normalize_tool_wrappers(wrappers: {ToolWrapperSpec}?): {ToolWrapperSpec} + local normalized: {ToolWrapperSpec} = {} + for _, wrapper in ipairs(wrappers or {}) do + if type(wrapper) == "table" then + normalized[#normalized + 1] = wrapper + end + end + table.sort(normalized, function(a, b) + local ap = a.priority or 100 + local bp = b.priority or 100 + if ap == bp then + return (a.order or 0) < (b.order or 0) + end + return ap < bp + end) + return normalized :: {ToolWrapperSpec} +end + +function tool_caller:set_tool_wrappers(wrappers: {ToolWrapperSpec}?): any + self.tool_wrappers = normalize_tool_wrappers(wrappers) + return self +end + +function tool_caller:set_wrapper_context(context: ToolWrapperExecutionContext?): any + self.wrapper_context = context or {} + return self +end + +function tool_caller:get_wrapper_observations(): {ToolWrapperObservation} + return self.wrapper_observations or {} +end + +function tool_caller:get_wrapper_errors(): {ToolWrapperError} + return self.wrapper_errors or {} +end + +function tool_caller:get_wrapper_metadata(): {ToolWrapperMetadata} + return self.wrapper_metadata or {} +end + +function tool_caller:get_last_tool_calls(): {ToolCall} + return self.last_tool_calls or {} +end + +local function reset_wrapper_diagnostics(self: any) + self.wrapper_observations = {} + self.wrapper_metadata = {} + self.wrapper_errors = {} +end + +local function wrapper_supports_phase(wrapper: ToolWrapperSpec, phase: ToolWrapperPhase): boolean + for _, wrapper_phase in ipairs(wrapper.phases or {}) do + if wrapper_phase == phase then + return true + end + end + return false +end + +local function infer_outcome(results: {[string]: ToolExecResult}?): ToolWrapperOutcome + for _, result in pairs(results or {}) do + if result and result.error then + return { + state = OUTCOME_STATE.CONTINUES, + reason = OUTCOME_REASON.TOOL_EXECUTION_FAILED + } + end + end + + return { + state = OUTCOME_STATE.CONTINUES, + reason = OUTCOME_REASON.TOOL_RESULTS_RECORDED + } +end + +local function copy_payload(payload: ToolWrapperApplyRequest?): ToolWrapperApplyRequest + local out = {} + for k, v in pairs(payload or {}) do + out[k] = v + end + return out :: ToolWrapperApplyRequest +end + +local function call_contract_wrapper(wrapper: ToolWrapperSpec, payload: ToolWrapperApplyRequest): (ToolWrapperApplyResponse?, string?) + local binding = wrapper.binding + if type(binding) ~= "string" or binding == "" then + return nil, "tool wrapper binding is required" + end + + local contract_def, contract_err = get_contract().get("wippy.agent:tool_wrapper") + if contract_err or not contract_def then + return nil, "failed to load tool_wrapper contract: " .. tostring(contract_err or "not found") + end + + local opener = contract_def + if opener.with_context then + opener = opener:with_context(wrapper.context or {}) + end + + local instance, open_err = opener:open(tostring(binding)) + if open_err or not instance then + return nil, "failed to open tool_wrapper binding: " .. tostring(open_err or "no instance") + end + + local result, apply_err = instance:apply(payload) + if apply_err then + return nil, tostring(apply_err) + end + return (type(result) == "table" and result or {}) :: ToolWrapperApplyResponse, nil +end + +local function call_wrapper(self: any, wrapper: ToolWrapperSpec, payload: ToolWrapperApplyRequest): (ToolWrapperApplyResponse?, string?) + return call_contract_wrapper(wrapper, payload) +end + +local function record_wrapper_result(self: any, wrapper: ToolWrapperSpec, phase: ToolWrapperPhase, result: ToolWrapperApplyResponse?) + if type(result) ~= "table" then + return + end + + for _, observation in ipairs(result.observations or {}) do + self.wrapper_observations[#self.wrapper_observations + 1] = observation + end + + if result.metadata ~= nil then + self.wrapper_metadata[#self.wrapper_metadata + 1] = { + wrapper_id = wrapper.id, + trait_id = wrapper.trait_id, + phase = phase, + metadata = result.metadata, + } + end +end + +local function record_wrapper_error(self: any, wrapper: ToolWrapperSpec, phase: ToolWrapperPhase, err: string) + self.wrapper_errors[#self.wrapper_errors + 1] = { + wrapper_id = wrapper.id, + trait_id = wrapper.trait_id, + phase = phase, + error = tostring(err), + strict = wrapper.strict == true, + } +end + +function tool_caller:apply_tool_wrappers(phase: ToolWrapperPhase, payload: ToolWrapperApplyRequest?): (ToolWrapperApplyRequest, string?) + local current_payload: ToolWrapperApplyRequest = copy_payload(payload) + current_payload.phase = phase + current_payload.host = current_payload.host or self.wrapper_context.host + current_payload.agent = current_payload.agent or self.wrapper_context.agent or {} + current_payload.run_context = current_payload.run_context or self.wrapper_context.run_context + current_payload.tool_calls = current_payload.tool_calls or {} + current_payload.options = current_payload.options or {} + + local wrappers: {ToolWrapperSpec} = self.tool_wrappers or {} + if #wrappers == 0 then + return current_payload, nil + end + + if type(current_payload.host) ~= "table" or type(current_payload.host.kind) ~= "string" or current_payload.host.kind == "" then + return current_payload, "tool wrapper host is required" + end + + if phase == PHASE.AFTER_EXECUTE and current_payload.outcome == nil then + current_payload.outcome = self.wrapper_context.outcome or infer_outcome(current_payload.tool_results) + end + + for _, wrapper in ipairs(wrappers) do + if wrapper_supports_phase(wrapper, phase) then + current_payload.options = wrapper.options or {} + + local result, err = call_wrapper(self, wrapper, current_payload :: ToolWrapperApplyRequest) + if err then + record_wrapper_error(self, wrapper, phase, err) + if wrapper.strict == true and phase == PHASE.BEFORE_EXECUTE then + return current_payload :: ToolWrapperApplyRequest, err + end + goto continue + end + + record_wrapper_result(self, wrapper, phase, result) + if phase == PHASE.BEFORE_EXECUTE and result and type(result.tool_calls) == "table" then + current_payload.tool_calls = result.tool_calls + end + end + + ::continue:: + end + + return current_payload :: ToolWrapperApplyRequest, nil +end + function tool_caller:validate(tool_calls: {ToolCall}?): (any, string?) -- Check if there are any tool calls if not tool_calls or #tool_calls == 0 then return {}, nil end + reset_wrapper_diagnostics(self) + + local before_payload: ToolWrapperApplyRequest = { + phase = BEFORE_EXECUTE :: ToolWrapperPhase, + host = self.wrapper_context.host, + agent = self.wrapper_context.agent, + tool_calls = tool_calls, + options = {}, + } + local wrapped_payload, wrapper_err = self:apply_tool_wrappers(BEFORE_EXECUTE, before_payload) + if wrapper_err then + return nil, "Tool wrapper failed: " .. tostring(wrapper_err) + end + + tool_calls = wrapped_payload.tool_calls or tool_calls + self.last_tool_calls = tool_calls + local validated_tools: {[string]: any} = {} local has_exclusive = false local exclusive_tool = nil @@ -309,15 +636,32 @@ function tool_caller:execute(context: any, validated_tools: any): any validated_tools = {} end + local results if self.strategy == STRATEGY.PARALLEL then - return execute_parallel(self :: any, context as table?, validated_tools) + results = execute_parallel(self :: any, context as table?, validated_tools) else - return execute_sequential(self :: any, context, validated_tools) + results = execute_sequential(self :: any, context, validated_tools) end + + local after_payload: ToolWrapperApplyRequest = { + phase = AFTER_EXECUTE :: ToolWrapperPhase, + host = self.wrapper_context.host, + agent = self.wrapper_context.agent, + tool_calls = self.last_tool_calls or {}, + tool_results = results :: {[string]: ToolExecResult}, + outcome = self.wrapper_context.outcome or infer_outcome(results), + options = {}, + } + self:apply_tool_wrappers(AFTER_EXECUTE, after_payload) + + return results end -- Export constants tool_caller.FUNC_STATUS = FUNC_STATUS tool_caller.STRATEGY = STRATEGY +tool_caller.PHASE = PHASE +tool_caller.OUTCOME_STATE = OUTCOME_STATE +tool_caller.OUTCOME_REASON = OUTCOME_REASON return tool_caller diff --git a/src/agent/src/tools/caller_test.lua b/src/agent/src/tools/caller_test.lua index e4c2c3f..0b496b6 100644 --- a/src/agent/src/tools/caller_test.lua +++ b/src/agent/src/tools/caller_test.lua @@ -12,6 +12,8 @@ local function define_tests() describe("Tool Caller", function() local tool_caller + local wrapper_calls + local wrapper_behaviors -- Mock tool schemas local tool_schemas = { @@ -65,6 +67,8 @@ local function define_tests() ["test:non_exclusive"] = { result = "regular_result" }, ["test:failing_tool"] = { error = "Tool execution failed" } } + wrapper_calls = {} + wrapper_behaviors = {} -- Create mock modules local mock_json = { @@ -161,18 +165,61 @@ local function define_tests() end } + local mock_contract = { + get = function(contract_id) + if contract_id ~= "wippy.agent:tool_wrapper" then + return nil, "unknown contract: " .. tostring(contract_id) + end + + local function open_with_context(binding_context) + return { + open = function(self, binding) + return { + apply = function(self, payload) + table.insert(wrapper_calls, { + binding = binding, + context = binding_context or {}, + payload = payload + }) + + local behavior = wrapper_behaviors[binding] + if behavior then + return behavior(payload, binding_context or {}) + end + return {}, nil + end + }, nil + end + } + end + + return { + with_context = function(self, binding_context) + return open_with_context(binding_context) + end, + open = function(self, binding) + return open_with_context({}):open(binding) + end + }, nil + end + } + -- Inject mocks via internal fields tool_caller = require("tool_caller") tool_caller._json = mock_json tool_caller._tools = mock_tools tool_caller._funcs = mock_funcs + tool_caller._contract = mock_contract end) after_each(function() tool_caller._json = nil tool_caller._tools = nil tool_caller._funcs = nil + tool_caller._contract = nil tool_caller = nil + wrapper_calls = nil + wrapper_behaviors = nil end) describe("Constructor and Strategy", function() @@ -739,6 +786,238 @@ local function define_tests() end) end) + describe("Tool Wrapper Contracts", function() + local function calculator_call(call_id) + return { + id = call_id or "call_calculator", + name = "calculator", + arguments = { expression = "2 + 2" }, + registry_id = "test:calculator" + } + end + + it("should apply focused before and after wrapper bindings without losing context", function() + wrapper_behaviors["test.wrapper:guard"] = function(payload, binding_context) + test.eq(payload.phase, tool_caller.PHASE.BEFORE_EXECUTE) + test.eq(payload.host.kind, "session") + test.eq(payload.host.session_id, "session-1") + test.eq(payload.agent.id, "agent-1") + test.eq(payload.options.max_calls, 1) + test.eq(binding_context.policy, "guard") + test.eq(binding_context.agent_id, "agent-1") + test.eq(binding_context.trait_id, "trait:guard") + + return { + tool_calls = { + { + id = "call_weather", + name = "get_weather", + arguments = { city = "NYC" }, + registry_id = "test:weather" + } + }, + observations = { + { + level = "info", + code = "guard.checked", + content = "tool calls checked" + } + }, + metadata = { + checked = true + } + }, nil + end + + wrapper_behaviors["test.wrapper:audit"] = function(payload, binding_context) + test.eq(payload.phase, tool_caller.PHASE.AFTER_EXECUTE) + test.eq(payload.host.kind, "session") + test.eq(payload.host.session_id, "session-1") + test.eq(payload.agent.model, "gpt-test") + test.eq(payload.run_context.contract, "wippy.agent:run_context") + test.eq(payload.run_context.binding, "wippy.session.run_context:binding") + test.eq(payload.options.include_results, true) + test.eq(binding_context.policy, "audit") + test.eq(payload.tool_calls[1].registry_id, "test:weather") + test.not_nil(payload.tool_results.call_weather) + test.eq(payload.tool_results.call_weather.result, "Sunny, 25°C") + test.eq(payload.outcome.state, tool_caller.OUTCOME_STATE.CONTINUES) + test.eq(payload.outcome.reason, tool_caller.OUTCOME_REASON.TOOL_RESULTS_RECORDED) + + return { + observations = { + { + level = "info", + code = "audit.recorded", + content = "tool results recorded" + } + } + }, nil + end + + local caller = tool_caller.new() + caller:set_tool_wrappers({ + { + id = "audit", + trait_id = "trait:audit", + phases = { tool_caller.PHASE.AFTER_EXECUTE }, + binding = "test.wrapper:audit", + priority = 20, + context = { + policy = "audit", + trait_id = "trait:audit" + }, + options = { + include_results = true + } + }, + { + id = "guard", + trait_id = "trait:guard", + phases = { tool_caller.PHASE.BEFORE_EXECUTE }, + binding = "test.wrapper:guard", + priority = 10, + context = { + policy = "guard", + agent_id = "agent-1", + trait_id = "trait:guard" + }, + options = { + max_calls = 1 + } + } + }) + caller:set_wrapper_context({ + host = { + kind = "session", + session_id = "session-1" + }, + agent = { + id = "agent-1", + model = "gpt-test" + }, + run_context = { + contract = "wippy.agent:run_context", + binding = "wippy.session.run_context:binding", + host = { + kind = "session", + session_id = "session-1" + } + } + }) + + local validated, validate_err = caller:validate({ calculator_call() }) + test.is_nil(validate_err) + test.not_nil(validated) + test.not_nil(validated.call_weather) + test.eq(validated.call_weather.registry_id, "test:weather") + + local results = caller:execute({}, validated) + test.not_nil(results.call_weather) + test.eq(results.call_weather.result, "Sunny, 25°C") + + test.eq(#wrapper_calls, 2) + test.eq(wrapper_calls[1].binding, "test.wrapper:guard") + test.eq(wrapper_calls[2].binding, "test.wrapper:audit") + + local observations = caller:get_wrapper_observations() + test.eq(#observations, 2) + test.eq(observations[1].code, "guard.checked") + test.eq(observations[2].code, "audit.recorded") + + local metadata = caller:get_wrapper_metadata() + test.eq(#metadata, 1) + test.eq(metadata[1].wrapper_id, "guard") + test.is_true(metadata[1].metadata.checked) + end) + + it("should require host context when wrappers are configured", function() + local caller = tool_caller.new() + caller:set_tool_wrappers({ + { + id = "guard", + phases = { tool_caller.PHASE.BEFORE_EXECUTE }, + binding = "test.wrapper:guard", + strict = true + } + }) + + local validated, err = caller:validate({ calculator_call() }) + + test.is_nil(validated) + test.not_nil(err) + test.not_nil(err:match("host is required")) + test.eq(#wrapper_calls, 0) + end) + + it("should block validation on strict before wrapper errors", function() + wrapper_behaviors["test.wrapper:guard"] = function(payload, binding_context) + return nil, "blocked by policy" + end + + local caller = tool_caller.new() + caller:set_tool_wrappers({ + { + id = "guard", + trait_id = "trait:guard", + phases = { tool_caller.PHASE.BEFORE_EXECUTE }, + binding = "test.wrapper:guard", + strict = true + } + }) + caller:set_wrapper_context({ + host = { kind = "dataflow", dataflow_id = "df-1", node_id = "node-1", iteration = 3 }, + agent = { id = "agent-1" } + }) + + local validated, err = caller:validate({ calculator_call() }) + + test.is_nil(validated) + test.not_nil(err) + test.not_nil(err:match("blocked by policy")) + + local wrapper_errors = caller:get_wrapper_errors() + test.eq(#wrapper_errors, 1) + test.eq(wrapper_errors[1].phase, tool_caller.PHASE.BEFORE_EXECUTE) + test.is_true(wrapper_errors[1].strict) + end) + + it("should preserve tool results on after wrapper errors", function() + wrapper_behaviors["test.wrapper:audit"] = function(payload, binding_context) + test.eq(payload.outcome.reason, tool_caller.OUTCOME_REASON.TOOL_RESULTS_RECORDED) + return nil, "audit sink unavailable" + end + + local caller = tool_caller.new() + caller:set_tool_wrappers({ + { + id = "audit", + trait_id = "trait:audit", + phases = { tool_caller.PHASE.AFTER_EXECUTE }, + binding = "test.wrapper:audit", + strict = false + } + }) + caller:set_wrapper_context({ + host = { kind = "dataflow", dataflow_id = "df-1", node_id = "node-1", iteration = 7 }, + agent = { id = "agent-1", model = "gpt-test" } + }) + + local validated, validate_err = caller:validate({ calculator_call("call_123") }) + test.is_nil(validate_err) + test.not_nil(validated) + + local results = caller:execute({}, validated) + test.not_nil(results.call_123) + test.eq(results.call_123.result, 42) + + local wrapper_errors = caller:get_wrapper_errors() + test.eq(#wrapper_errors, 1) + test.eq(wrapper_errors[1].phase, tool_caller.PHASE.AFTER_EXECUTE) + test.is_false(wrapper_errors[1].strict) + end) + end) + describe("Interface Completeness", function() it("should have all required methods", function() local caller = tool_caller.new() @@ -746,6 +1025,8 @@ local function define_tests() test.eq(type(caller.validate), "function") test.eq(type(caller.execute), "function") test.eq(type(caller.set_strategy), "function") + test.eq(type(caller.set_tool_wrappers), "function") + test.eq(type(caller.set_wrapper_context), "function") end) it("should maintain consistent interface", function() @@ -1276,4 +1557,4 @@ local function define_tests() end) end -return require("test").run_cases(define_tests) \ No newline at end of file +return require("test").run_cases(define_tests) diff --git a/src/llm/src/discovery/_index.yaml b/src/llm/src/discovery/_index.yaml index eccb113..8d0afc2 100644 --- a/src/llm/src/discovery/_index.yaml +++ b/src/llm/src/discovery/_index.yaml @@ -27,6 +27,8 @@ entries: - classes - tests source: file://models_test.lua + modules: + - registry imports: models: wippy.llm.discovery:models test: wippy.test:test @@ -58,6 +60,9 @@ entries: - contracts - tests source: file://providers_test.lua + modules: + - registry + - contract imports: providers: wippy.llm.discovery:providers test: wippy.test:test diff --git a/src/llm/src/google/_index.yaml b/src/llm/src/google/_index.yaml index fba9c0a..570e823 100644 --- a/src/llm/src/google/_index.yaml +++ b/src/llm/src/google/_index.yaml @@ -203,6 +203,7 @@ entries: - json imports: google_generate: wippy.llm.google:generate + google_config: wippy.llm.google:config test: wippy.test:test method: run_tests diff --git a/src/llm/src/llm.lua b/src/llm/src/llm.lua index 78ab72f..d5e7c52 100644 --- a/src/llm/src/llm.lua +++ b/src/llm/src/llm.lua @@ -342,6 +342,30 @@ local function merge_user_options(contract_args, user_options, exclude_keys) end end +local function apply_provider_transport(contract_args, provider_info) + local context = provider_info and provider_info.context + if type(context) ~= "table" then return end + if contract_args.timeout == nil and context.timeout ~= nil then + contract_args.timeout = context.timeout + end + if contract_args.retry == nil and context.retry ~= nil then + contract_args.retry = context.retry + end +end + +local function hoist_transport_options(contract_args) + local opts = contract_args.options + if type(opts) ~= "table" then return end + if opts.timeout ~= nil then + contract_args.timeout = opts.timeout + opts.timeout = nil + end + if opts.retry ~= nil then + contract_args.retry = opts.retry + opts.retry = nil + end +end + --------------------------- -- Constants (Backward Compatibility) --------------------------- @@ -420,6 +444,7 @@ function llm.generate(prompt_input, options) -- Copy user options to contract options (no provider options in direct mode) merge_user_options(contract_args, options, {"model", "provider_id"}) + hoist_transport_options(contract_args) contract_args._provider_id = provider_info.id -- Call provider contract directly with standard format @@ -479,9 +504,11 @@ function llm.generate(prompt_input, options) -- Merge provider options first (from model YAML) merge_provider_options(contract_args, provider_info) + apply_provider_transport(contract_args, provider_info) -- Merge user options (can override provider defaults) merge_user_options(contract_args, options, {"model"}) + hoist_transport_options(contract_args) -- Call provider contract local raw_result, err = (provider_instance as any):generate(contract_args) @@ -554,6 +581,7 @@ function llm.structured_output(schema, prompt_input, options): (GenerateResponse -- Copy user options to contract options (no provider options in direct mode) merge_user_options(contract_args, options, {"model", "provider_id", "schema"}) + hoist_transport_options(contract_args) contract_args._provider_id = provider_info.id -- Call provider contract directly with standard format @@ -615,9 +643,11 @@ function llm.structured_output(schema, prompt_input, options): (GenerateResponse -- Merge provider options first (from model YAML) merge_provider_options(contract_args, provider_info) + apply_provider_transport(contract_args, provider_info) -- Merge user options (can override provider defaults) merge_user_options(contract_args, options, {"model", "schema"}) + hoist_transport_options(contract_args) local raw_result, err = (provider_instance as any):structured_output(contract_args) if err then @@ -678,6 +708,7 @@ function llm.embed(text, options) -- Copy user options to contract options (no provider options in direct mode) merge_user_options(contract_args, options, {"model", "provider_id"}) + hoist_transport_options(contract_args) contract_args._provider_id = provider_info.id local raw_result, err = (provider_instance as any):embed(contract_args) @@ -738,9 +769,11 @@ function llm.embed(text, options) -- Merge provider options first (from model YAML) merge_provider_options(contract_args, provider_info) + apply_provider_transport(contract_args, provider_info) -- Merge user options (can override provider defaults) merge_user_options(contract_args, options, {"model", "dimensions"}) + hoist_transport_options(contract_args) local raw_result, err = (provider_instance as any):embed(contract_args) if err then @@ -793,6 +826,7 @@ function llm.status(options) end provider_info = model_card.providers[1] as any merge_provider_options(contract_args, provider_info) + apply_provider_transport(contract_args, provider_info) end local providers_module = llm._providers or providers @@ -802,6 +836,7 @@ function llm.status(options) end merge_user_options(contract_args, options, {"model", "provider_id"}) + hoist_transport_options(contract_args) local result, err = (provider_instance as any):status(contract_args) diff --git a/src/llm/src/openai/_index.yaml b/src/llm/src/openai/_index.yaml index 7336294..3ec7ce3 100644 --- a/src/llm/src/openai/_index.yaml +++ b/src/llm/src/openai/_index.yaml @@ -30,6 +30,7 @@ entries: - env - ctx - security + - time # wippy.llm.openai:client_test - name: client_test @@ -98,6 +99,9 @@ entries: description: Request timeout in seconds default_env: OPENAI_TIMEOUT default_value: 600 + retry: + type: object + description: Provider-level retry policy for transient transport/provider failures # wippy.llm.openai:embed - name: embed diff --git a/src/llm/src/openai/client.lua b/src/llm/src/openai/client.lua index 925a1f9..f2d0fd7 100644 --- a/src/llm/src/openai/client.lua +++ b/src/llm/src/openai/client.lua @@ -2,12 +2,14 @@ local json = require("json") local http_client = require("http_client") local env = require("env") local ctx = require("ctx") +local time = require("time") type OpenAIConfig = { api_key: string?, base_url: string, organization: string?, timeout: number, + retry: table?, headers: {[string]: string}? } @@ -25,6 +27,37 @@ openai_client._http_client = http_client openai_client._env = env openai_client._ctx = ctx +local function normalize_retry(raw) + if type(raw) ~= "table" then return nil end + local attempts = math.floor(tonumber(raw.attempts) or 0) + if attempts <= 0 then return nil end + if attempts > 10 then attempts = 10 end + + local backoff_ms = math.floor(tonumber(raw.backoff_ms) or 500) + if backoff_ms < 0 then backoff_ms = 0 end + if backoff_ms > 60000 then backoff_ms = 60000 end + + return { attempts = attempts, backoff_ms = backoff_ms } +end + +local function retryable_error(error_info) + local status = tonumber(error_info and error_info.status_code) or 0 + return status == 0 + or status == 408 + or status == 409 + or status == 425 + or status == 429 + or (status >= 500 and status < 600) +end + +local function sleep_for_retry(retry, attempt) + if not retry then return end + local delay_ms = (tonumber(retry.backoff_ms) or 500) * (2 ^ math.max(0, attempt - 1)) + if delay_ms <= 0 then return end + if delay_ms > 60000 then delay_ms = 60000 end + time.sleep(tostring(math.floor(delay_ms)) .. "ms") +end + local function resolve_config() local ctx_all = openai_client._ctx.all() or {} @@ -49,6 +82,7 @@ local function resolve_config() base_url = resolve_string("base_url", "OPENAI_BASE_URL") or "https://api.openai.com/v1", organization = resolve_string("organization", "OPENAI_ORGANIZATION"), timeout = tonumber(resolve_string("timeout", "OPENAI_TIMEOUT")) or 600, + retry = normalize_retry(ctx_all.retry), headers = ctx_all.headers } return config @@ -162,28 +196,44 @@ function openai_client.request(endpoint_path, payload, options) http_options.body = json.encode(payload) end - local response, err - if method == "GET" then - response, err = openai_client._http_client.get(full_url, http_options) - elseif method == "DELETE" then - response, err = openai_client._http_client.delete(full_url, http_options) - elseif method == "PUT" then - response, err = openai_client._http_client.put(full_url, http_options) - elseif method == "PATCH" then - response, err = openai_client._http_client.patch(full_url, http_options) - else - response, err = openai_client._http_client.post(full_url, http_options) + local function send_once() + if method == "GET" then + return openai_client._http_client.get(full_url, http_options) + elseif method == "DELETE" then + return openai_client._http_client.delete(full_url, http_options) + elseif method == "PUT" then + return openai_client._http_client.put(full_url, http_options) + elseif method == "PATCH" then + return openai_client._http_client.patch(full_url, http_options) + end + return openai_client._http_client.post(full_url, http_options) end - if not response then - return nil, { - status_code = 0, - message = err and ("Connection failed: " .. tostring(err)) or "Connection failed" - } - end + local retry = normalize_retry(options.retry) or config.retry + local response, err + local request_error = nil + local retry_count = 0 + while true do + response, err = send_once() + + if not response then + request_error = { + status_code = 0, + message = err and ("Connection failed: " .. tostring(err)) or "Connection failed" + } + elseif response.status_code < 200 or response.status_code >= 300 then + request_error = parse_error_response(response) + else + request_error = nil + end + + if not request_error then break end + if not retry or retry_count >= retry.attempts or not retryable_error(request_error) then + return nil, request_error + end - if response.status_code < 200 or response.status_code >= 300 then - return nil, parse_error_response(response) + retry_count = retry_count + 1 + sleep_for_retry(retry, retry_count) end if options.stream and response.stream then diff --git a/src/llm/src/openai/embed.lua b/src/llm/src/openai/embed.lua index a2cbfd2..91a2bbb 100644 --- a/src/llm/src/openai/embed.lua +++ b/src/llm/src/openai/embed.lua @@ -35,7 +35,8 @@ function embeddings_handler.handler(contract_args) end local openai_response, req_err = embeddings_handler._client.request("/embeddings", openai_payload, { - timeout = contract_args.timeout + timeout = contract_args.timeout, + retry = contract_args.retry, }) if req_err then diff --git a/src/llm/src/openai/generate.lua b/src/llm/src/openai/generate.lua index de8644f..82e10e3 100644 --- a/src/llm/src/openai/generate.lua +++ b/src/llm/src/openai/generate.lua @@ -162,7 +162,8 @@ function generate_handler.handler(contract_args) end local request_options = { - timeout = contract_args.timeout or 600 + timeout = contract_args.timeout or 600, + retry = contract_args.retry, } local stream_config = nil diff --git a/src/llm/src/openai/structured_output.lua b/src/llm/src/openai/structured_output.lua index 52af873..3476040 100644 --- a/src/llm/src/openai/structured_output.lua +++ b/src/llm/src/openai/structured_output.lua @@ -127,7 +127,8 @@ function structured_output_handler.handler(contract_args) end local request_options = { - timeout = contract_args.timeout + timeout = contract_args.timeout, + retry = contract_args.retry, } local response, req_err = structured_output_handler._client.request("/responses", payload, request_options) diff --git a/src/llm/src/openai_compat/_index.yaml b/src/llm/src/openai_compat/_index.yaml index 9c717be..df2b3ff 100644 --- a/src/llm/src/openai_compat/_index.yaml +++ b/src/llm/src/openai_compat/_index.yaml @@ -30,6 +30,7 @@ entries: - env - ctx - security + - time # wippy.llm.openai_compat:client_test - name: client_test @@ -98,6 +99,9 @@ entries: description: Request timeout in seconds default_env: OPENAI_COMPAT_TIMEOUT default_value: 600 + retry: + type: object + description: Provider-level retry policy for transient transport/provider failures # wippy.llm.openai_compat:embed - name: embed diff --git a/src/llm/src/openai_compat/client.lua b/src/llm/src/openai_compat/client.lua index b50a39a..0089491 100644 --- a/src/llm/src/openai_compat/client.lua +++ b/src/llm/src/openai_compat/client.lua @@ -2,12 +2,14 @@ local json = require("json") local http_client = require("http_client") local env = require("env") local ctx = require("ctx") +local time = require("time") type OpenAIConfig = { api_key: string?, base_url: string, organization: string?, timeout: number, + retry: table?, headers: {[string]: string}? } @@ -25,6 +27,37 @@ openai_client._http_client = http_client openai_client._env = env openai_client._ctx = ctx +local function normalize_retry(raw) + if type(raw) ~= "table" then return nil end + local attempts = math.floor(tonumber(raw.attempts) or 0) + if attempts <= 0 then return nil end + if attempts > 10 then attempts = 10 end + + local backoff_ms = math.floor(tonumber(raw.backoff_ms) or 500) + if backoff_ms < 0 then backoff_ms = 0 end + if backoff_ms > 60000 then backoff_ms = 60000 end + + return { attempts = attempts, backoff_ms = backoff_ms } +end + +local function retryable_error(error_info) + local status = tonumber(error_info and error_info.status_code) or 0 + return status == 0 + or status == 408 + or status == 409 + or status == 425 + or status == 429 + or (status >= 500 and status < 600) +end + +local function sleep_for_retry(retry, attempt) + if not retry then return end + local delay_ms = (tonumber(retry.backoff_ms) or 500) * (2 ^ math.max(0, attempt - 1)) + if delay_ms <= 0 then return end + if delay_ms > 60000 then delay_ms = 60000 end + time.sleep(tostring(math.floor(delay_ms)) .. "ms") +end + local function resolve_config() local ctx_all = openai_client._ctx.all() or {} @@ -49,6 +82,7 @@ local function resolve_config() base_url = resolve_string("base_url", "OPENAI_COMPAT_BASE_URL") or "https://api.openai.com/v1", organization = resolve_string("organization", "OPENAI_COMPAT_ORGANIZATION"), timeout = tonumber(resolve_string("timeout", "OPENAI_COMPAT_TIMEOUT")) or 600, + retry = normalize_retry(ctx_all.retry), headers = ctx_all.headers } return config @@ -205,32 +239,44 @@ function openai_client.request(endpoint_path, payload, options) end end - -- Make the HTTP request using appropriate method - local response, err - if method == "GET" then - response, err = openai_client._http_client.get(full_url, http_options) - elseif method == "DELETE" then - response, err = openai_client._http_client.delete(full_url, http_options) - elseif method == "PUT" then - response, err = openai_client._http_client.put(full_url, http_options) - elseif method == "PATCH" then - response, err = openai_client._http_client.patch(full_url, http_options) - else -- Default to POST - response, err = openai_client._http_client.post(full_url, http_options) + local function send_once() + if method == "GET" then + return openai_client._http_client.get(full_url, http_options) + elseif method == "DELETE" then + return openai_client._http_client.delete(full_url, http_options) + elseif method == "PUT" then + return openai_client._http_client.put(full_url, http_options) + elseif method == "PATCH" then + return openai_client._http_client.patch(full_url, http_options) + end + return openai_client._http_client.post(full_url, http_options) end - -- Handle nil response (connection failures) - if not response then - return nil, { - status_code = 0, - message = err and ("Connection failed: " .. tostring(err)) or "Connection failed" - } - end + local retry = normalize_retry(options.retry) or config.retry + local response, err + local request_error = nil + local retry_count = 0 + while true do + response, err = send_once() + + if not response then + request_error = { + status_code = 0, + message = err and ("Connection failed: " .. tostring(err)) or "Connection failed" + } + elseif response.status_code < 200 or response.status_code >= 300 then + request_error = parse_error_response(response) + else + request_error = nil + end + + if not request_error then break end + if not retry or retry_count >= retry.attempts or not retryable_error(request_error) then + return nil, request_error + end - -- Check for HTTP errors - if response.status_code < 200 or response.status_code >= 300 then - local parsed_error = parse_error_response(response) - return nil, parsed_error + retry_count = retry_count + 1 + sleep_for_retry(retry, retry_count) end -- Handle streaming response diff --git a/src/llm/src/openai_compat/embed.lua b/src/llm/src/openai_compat/embed.lua index a2cbfd2..91a2bbb 100644 --- a/src/llm/src/openai_compat/embed.lua +++ b/src/llm/src/openai_compat/embed.lua @@ -35,7 +35,8 @@ function embeddings_handler.handler(contract_args) end local openai_response, req_err = embeddings_handler._client.request("/embeddings", openai_payload, { - timeout = contract_args.timeout + timeout = contract_args.timeout, + retry = contract_args.retry, }) if req_err then diff --git a/src/llm/src/openai_compat/generate.lua b/src/llm/src/openai_compat/generate.lua index 6ac3102..b750244 100644 --- a/src/llm/src/openai_compat/generate.lua +++ b/src/llm/src/openai_compat/generate.lua @@ -150,6 +150,7 @@ function generate_handler.handler(contract_args) local request_options = { timeout = contract_args.timeout or 600, + retry = contract_args.retry, } local stream_config = nil diff --git a/src/llm/src/openai_compat/structured_output.lua b/src/llm/src/openai_compat/structured_output.lua index da21683..ee142f3 100644 --- a/src/llm/src/openai_compat/structured_output.lua +++ b/src/llm/src/openai_compat/structured_output.lua @@ -119,7 +119,8 @@ function structured_output_handler.handler(contract_args) end local request_options = { - timeout = contract_args.timeout + timeout = contract_args.timeout, + retry = contract_args.retry, } local response, req_err = structured_output_handler._client.request( diff --git a/src/llm/src/output.lua b/src/llm/src/output.lua index aab4159..7118f34 100644 --- a/src/llm/src/output.lua +++ b/src/llm/src/output.lua @@ -150,40 +150,34 @@ local ErrorBuilder = {} ErrorBuilder.__index = ErrorBuilder function ErrorBuilder:with_contract(contract_args: ErrorContract): ErrorBuilder - local s = self :: any - s._contract = contract_args - return s :: ErrorBuilder + self._contract = contract_args + return self end function ErrorBuilder:classifier(fn: ClassifyError): ErrorBuilder - local s = self :: any - s._classifier = fn - return s :: ErrorBuilder + self._classifier = fn + return self end function ErrorBuilder:kind(k: string): ErrorBuilder - local s = self :: any - s._kind = k - return s :: ErrorBuilder + self._kind = k + return self end function ErrorBuilder:message(m: string): ErrorBuilder - local s = self :: any - s._message = m - return s :: ErrorBuilder + self._message = m + return self end function ErrorBuilder:details(d: table): ErrorBuilder - local s = self :: any - s._details = d - return s :: ErrorBuilder + self._details = d + return self end function ErrorBuilder:from(http_err: any?): ErrorBuilder - local s = self :: any - s._http_err = http_err - s._has_http_err = true -- distinguishes :from(nil) from "never called" - return s :: ErrorBuilder + self._http_err = http_err + self._has_http_err = true -- distinguishes :from(nil) from "never called" + return self end function ErrorBuilder:build(): StructuredError