## Identity You are Proof Soul, a governed Temper agent used to verify the Pi architecture rewrite.
## Instructions - Prefer deterministic mock runs for verification. - Surface memory and skills in the prompt. - Use tools only when the proof plan requires them.
## Capabilities - Run | PASS |
+| A6 | Thinking/Executing loop is visible in events | ProcessToolCalls/HandleToolResults present | PASS |
+| A7 | Session tree persisted JSONL entries and steering branch | {"id":"h-019d1fc4-f16f-7452-9119-79ae692dc5ae","parentId":null,"tokens":0,"type":"header","version":1} {"content":"{\"mock_plan\":{\"steps\":[{\"text\":\"Starting direct path\",\"tool_calls\":[{\"name\":\"bash\",\"input\":{\"command\":\"sle | PASS |
+| A8 | Steering injection stored and observable | steering marker present | PASS |
+| A9 | Steering caused a continue transition | ContinueWithSteering seen | PASS |
+| A10 | Agent completed successfully | Direct path finished with memory keys user-profile, project-context, proof-direct-memory. | PASS |
+| A11 | save_memory created a new AgentMemory | count=1 | PASS |
+
+## Trigger Path B: Channel Webhook
+| Step | Expected | Actual | Status |
+|---|---|---|---|
+| B1 | Channel.ReceiveMessage accepted webhook payload | ReceiveMessage executed | PASS |
+| B2 | ChannelSession created for thread | session_id=019d1fc5-0717-7c22-a206-598ccf05f8b7 | PASS |
+| B3 | Channel route spawned agent with route soul_id | soul_id=019d1fc4-f103-7500-9043-a09663bebb2e | PASS |
+| B4 | Channel-triggered agent completed | Channel proof reply | PASS |
+| B5 | send_reply delivered the agent result | {"path": "/", "body": "{\"agent_entity_id\":\"019d1fc5-06f9-7ad0-a252-bc6d34187024\",\"content\":\"Channel proof reply\",\"thread_id\":\"thread-1\"}", "agent_entity_id": "019d1fc5-06f9-7ad0-a252-bc6d34187024", "content": "Channel proof reply", "thread_id": "thread-1"} | PASS |
+
+## Trigger Path C: WASM Orchestration
+| Step | Expected | Actual | Status |
+|---|---|---|---|
+| C1 | An orchestrator entity ran WASM that spawned a TemperAgent | parent_agent=019d1fc5-0a03-78d0-a9d4-c410a869dd27 | PASS |
+| C2 | Child TemperAgent created with parent_agent_id | parent_agent_id=019d1fc5-0a03-78d0-a9d4-c410a869dd27 | PASS |
+| C3 | Child agent completed and result was observable | Child completed after steering: STEERED-CHILD | PASS |
+
+## Trigger Path D: MCP Tool Call
+| Step | Expected | Actual | Status |
+|---|---|---|---|
+| D1 | MCP created, configured, and provisioned an agent | agent_id=019d1fc5-19ab-7043-9830-8b10b06e0d44 | PASS |
+| D2 | MCP-observed agent reached Completed | MCP path ok | PASS |
+| D3 | MCP result matched expected output | MCP path ok | PASS |
+
+## Trigger Path E: Cron Job
+| Step | Expected | Actual | Status |
+|---|---|---|---|
+| E1 | CronJob entity created | cron_id=019d1fc5-1b06-71b1-bac0-ca53d64e2d5f | PASS |
+| E2 | Cron job activated | status=Active | PASS |
+| E3 | Manual Trigger action executed | last_agent_id=019d1fc5-1b28-7461-b862-709e30b2b274 | PASS |
+| E4 | Cron-triggered TemperAgent was created | agent_id=019d1fc5-1b28-7461-b862-709e30b2b274 | PASS |
+| E5 | CronJob tracked last_agent_id | LastAgentId=019d1fc5-1b28-7461-b862-709e30b2b274 | PASS |
+| E6 | Second trigger incremented run_count | RunCount=2 | PASS |
+
+## Subagent + Coding Agent Verification
+| Step | Expected | Actual | Status |
+|---|---|---|---|
+| S1 | Parent agent created with spawn_agent in tools | tools_enabled includes spawn_agent | PASS |
+| S2 | Parent invoked spawn_agent | child id present in parent session | PASS |
+| S3 | Child links back to parent | ParentAgentId=019d1fc5-0a03-78d0-a9d4-c410a869dd27 | PASS |
+| S4 | Parent steered child agent | Child completed after steering: STEERED-CHILD | PASS |
+| S5 | list_agents exposed child status | child id visible in tool result | PASS |
+| S6 | Parent/child flow produced child result | Child completed after steering: STEERED-CHILD | PASS |
+| S7 | Parent invoked run_coding_agent | tool result captured | PASS |
+| S8 | CLI command matched expected claude-code pattern | command string present | PASS |
+| S9 | agent_depth guard prevented deep recursion | guard message present | PASS |
+
+## Heartbeat Monitoring Verification
+| Step | Expected | Actual | Status |
+|---|---|---|---|
+| H1 | Heartbeat test agent created with short timeout | agent_id=019d1fc5-1c79-7f01-9671-5e3c358057bf | PASS |
+| H2 | Mock hang plan provisioned | provider=mock, mode=hang | PASS |
+| H3 | Heartbeat monitor started and scanned | monitor_id=019d1fc5-2086-7c90-8536-4c33a96a7e45 | PASS |
+| H4 | Stale agent transitioned to Failed | heartbeat timeout: no heartbeat observed within 300 seconds | PASS |
+| H5 | SSE replay captured TimeoutFail state change | TimeoutFail present | PASS |
+
+## Cross-Session Memory
+| Step | Expected | Actual | Status |
+|---|---|---|---|
+| M1 | Second agent created with same soul_id | agent_id=019d1fc5-29ab-7193-b32d-539ce4388c08 | PASS |
+| M2 | Cross-session memory loaded into prompt | memory keys=user-profile, project-context, proof-direct-memory count=3 | PASS |
+| M3 | Memory-aware mock response surfaced recalled knowledge | memory keys=user-profile, project-context, proof-direct-memory count=3 | PASS |
+
+## Compaction
+| Step | Expected | Actual | Status |
+|---|---|---|---|
+| X1 | Compaction entry was written into the session tree | compaction entry present | PASS |
+| X2 | Agent resumed after compaction | [Previous conversation summary] ## Goal Preserve the active task.
## Constraints & Preferences Stay within the current workspace and existing agent context.
## Progress - Done: Earlier conversation was compacted. - In Progress: Continue the active task with the remaining context. - Blocked: None.
## Key Decisions Use the deterministic mock compaction path when no real model is configured.
## Next Steps Resume the agent loop after compaction.
## Critical Context ## user {"notes": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX | PASS |
+
+## Artifacts
+
+### Session Tree Dump
+```jsonl
+{"id":"h-019d1fc4-f16f-7452-9119-79ae692dc5ae","parentId":null,"tokens":0,"type":"header","version":1}
+{"content":"{\"mock_plan\":{\"steps\":[{\"text\":\"Starting direct path\",\"tool_calls\":[{\"name\":\"bash\",\"input\":{\"command\":\"sleep 2 && printf direct-path-bash\",\"workdir\":\"/Users/seshendranalla/Development/temper-pi-agent-rewrite/.tmp/temper-agent-proof/sandbox\"}}]},{\"final_text\":\"Waiting for steering check.\"},{\"text\":\"Steering applied: {{latest_user}}\",\"tool_calls\":[{\"name\":\"save_memory\",\"input\":{\"key\":\"proof-direct-memory\",\"content\":\"saved from direct path\",\"memory_type\":\"project\"}}]},{\"final_text\":\"Direct path finished with memory keys {{memory_keys}}.\"}]}}","id":"u-019d1fc4-f16f-7452-9119-79ae692dc5ae-0","parentId":"h-019d1fc4-f16f-7452-9119-79ae692dc5ae","role":"user","tokens":135,"type":"message"}
+{"content":[{"text":"Starting direct path","type":"text"},{"id":"mock-tool-0-0","input":{"command":"sleep 2 && printf direct-path-bash","workdir":"/Users/seshendranalla/Development/temper-pi-agent-rewrite/.tmp/temper-agent-proof/sandbox"},"name":"bash","type":"tool_use"}],"id":"a-2","parentId":"u-019d1fc4-f16f-7452-9119-79ae692dc5ae-0","role":"assistant","tokens":257,"type":"message"}
+{"content":[{"content":"direct-path-bash","is_error":false,"tool_use_id":"mock-tool-0-0","type":"tool_result"}],"id":"t-3","parentId":"a-2","role":"user","tokens":25,"type":"message"}
+{"content":[{"text":"Waiting for steering check.","type":"text"}],"id":"a-4","parentId":"t-3","role":"assistant","tokens":27,"type":"message"}
+{"content":"Follow the steering marker ST-123","id":"s-5","parentId":"a-4","role":"user","tokens":8,"type":"steering"}
+{"content":[{"text":"Steering applied: Follow the steering marker ST-123","type":"text"},{"id":"mock-tool-2-0","input":{"content":"saved from direct path","key":"proof-direct-memory","memory_type":"project"},"name":"save_memory","type":"tool_use"}],"id":"a-6","parentId":"s-5","role":"assistant","tokens":237,"type":"message"}
+{"content":[{"content":"Memory saved: key=proof-direct-memory, type=project","is_error":false,"tool_use_id":"mock-tool-2-0","type":"tool_result"}],"id":"t-7","parentId":"a-6","role":"user","tokens":33,"type":"message"}
+{"content":[{"text":"Direct path finished with memory keys user-profile, project-context, proof-direct-memory.","type":"text"}],"id":"a-8","parentId":"t-7","role":"assistant","tokens":89,"type":"message"}
+```
+
+### SSE Events Captured
+```text
+event: state_change
+data: {"seq":1,"entity_type":"TemperAgent","entity_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","action":"Created","status":"Created","tenant":"temper-agent-proof-20260324121451"}
+
+event: state_change
+data: {"seq":2,"entity_type":"TemperAgent","entity_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","action":"Configure","status":"Created","tenant":"temper-agent-proof-20260324121451"}
+
+event: state_change
+data: {"seq":3,"entity_type":"TemperAgent","entity_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","action":"Provision","status":"Provisioning","tenant":"temper-agent-proof-20260324121451"}
+
+event: integration_start
+data: {"seq":4,"integration":"provision_sandbox","module":"sandbox_provisioner","trigger_action":"Provision"}
+
+event: integration_complete
+data: {"seq":5,"integration":"provision_sandbox","module":"sandbox_provisioner","trigger_action":"Provision","result":"success","callback_action":"SandboxReady","duration_ms":285}
+
+event: state_change
+data: {"seq":6,"entity_type":"TemperAgent","entity_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","action":"SandboxReady","status":"Thinking","tenant":"temper-agent-proof-20260324121451","agent_id":"system"}
+
+event: integration_start
+data: {"seq":7,"integration":"call_llm","module":"llm_caller","trigger_action":"SandboxReady"}
+
+event: prompt_assembled
+data: {"tenant":"temper-agent-proof-20260324121451","entity_type":"TemperAgent","entity_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","seq":8,"kind":"prompt_assembled","agent_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","tool_call_id":null,"tool_name":"llm_caller","task_id":null,"message":"system prompt assembled","timestamp":"2026-03-24T12:14:54.161741+00:00","data":{"kind":"prompt_assembled","message":"system prompt assembled","system_prompt":"# Proof Soul\n\n## Identity\nYou are Proof Soul, a governed Temper agent used to verify the Pi architecture rewrite.\n\n## Instructions\n- Prefer deterministic mock runs for verification.\n- Surface memory and skills in the prompt.\n- Use tools only when the proof plan requires them.\n\n## Capabilities\n- Run sandbox tools\n- Spawn governed child agents\n- Save and recall memories\n\n## Constraints\n- Do not use destructive commands.\n- Stay inside the provided workspace.\n\n\nOverride: include the DIRECT-OVERRIDE marker.\n\n\n \n \n\n\n\n \n The proof user prefers exact verification over discussion.\n \n \n Temper Pi rewrite proof must capture SSE, session trees, cron, heartbeat, channels, and MCP.\n \n"}}
+
+event: state_change
+data: {"seq":9,"entity_type":"TemperAgent","entity_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","action":"Heartbeat","status":"Thinking","tenant":"temper-agent-proof-20260324121451"}
+
+event: llm_request_started
+data: {"tenant":"temper-agent-proof-20260324121451","entity_type":"TemperAgent","entity_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","seq":10,"kind":"llm_request_started","agent_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","tool_call_id":null,"tool_name":"llm_caller","task_id":null,"message":"calling provider=mock model=mock-proof","timestamp":"2026-03-24T12:14:54.174224+00:00","data":{"kind":"llm_request_started","message":"calling provider=mock model=mock-proof"}}
+
+event: llm_response
+data: {"tenant":"temper-agent-proof-20260324121451","entity_type":"TemperAgent","entity_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","seq":11,"kind":"llm_response","agent_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","tool_call_id":null,"tool_name":"llm_caller","task_id":null,"message":"provider returned stop_reason=tool_use","timestamp":"2026-03-24T12:14:54.174579+00:00","data":{"kind":"llm_response","message":"provider returned stop_reason=tool_use","stop_reason":"tool_use"}}
+
+event: integration_complete
+data: {"seq":12,"integration":"call_llm","module":"llm_caller","trigger_action":"SandboxReady","result":"success","callback_action":"ProcessToolCalls","duration_ms":70}
+
+event: state_change
+data: {"seq":13,"entity_type":"TemperAgent","entity_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","action":"ProcessToolCalls","status":"Executing","tenant":"temper-agent-proof-20260324121451","agent_id":"system"}
+
+event: integration_start
+data: {"seq":14,"integration":"run_tools","module":"tool_runner","trigger_action":"ProcessToolCalls"}
+
+event: state_change
+data: {"seq":15,"entity_type":"TemperAgent","entity_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","action":"Heartbeat","status":"Executing","tenant":"temper-agent-proof-20260324121451"}
+
+event: tool_execution_start
+data: {"tenant":"temper-agent-proof-20260324121451","entity_type":"TemperAgent","entity_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","seq":16,"kind":"tool_execution_start","agent_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","tool_call_id":"mock-tool-0-0","tool_name":"bash","task_id":null,"message":"executing tool bash","timestamp":"2026-03-24T12:14:54.235977+00:00","data":{"kind":"tool_execution_start","message":"executing tool bash","tool_call_id":"mock-tool-0-0","tool_name":"bash"}}
+
+event: state_change
+data: {"seq":17,"entity_type":"TemperAgent","entity_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","action":"Steer","status":"Executing","tenant":"temper-agent-proof-20260324121451"}
+
+event: state_change
+data: {"seq":18,"entity_type":"TemperAgent","entity_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","action":"Heartbeat","status":"Executing","tenant":"temper-agent-proof-20260324121451"}
+
+event: tool_execution_complete
+data: {"tenant":"temper-agent-proof-20260324121451","entity_type":"TemperAgent","entity_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","seq":19,"kind":"tool_execution_complete","agent_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","tool_call_id":"mock-tool-0-0","tool_name":"bash","task_id":null,"message":"completed tool bash","timestamp":"2026-03-24T12:14:56.350986+00:00","data":{"is_error":false,"kind":"tool_execution_complete","message":"completed tool bash","tool_call_id":"mock-tool-0-0","tool_name":"bash"}}
+
+event: integration_complete
+data: {"seq":20,"integration":"run_tools","module":"tool_runner","trigger_action":"ProcessToolCalls","result":"success","callback_action":"HandleToolResults","duration_ms":2203}
+
+event: state_change
+data: {"seq":21,"entity_type":"TemperAgent","entity_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","action":"HandleToolResults","status":"Thinking","tenant":"temper-agent-proof-20260324121451","agent_id":"system"}
+
+event: integration_start
+data: {"seq":22,"integration":"call_llm","module":"llm_caller","trigger_action":"HandleToolResults"}
+
+event: prompt_assembled
+data: {"tenant":"temper-agent-proof-20260324121451","entity_type":"TemperAgent","entity_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","seq":23,"kind":"prompt_assembled","agent_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","tool_call_id":null,"tool_name":"llm_caller","task_id":null,"message":"system prompt assembled","timestamp":"2026-03-24T12:14:56.454475+00:00","data":{"kind":"prompt_assembled","message":"system prompt assembled","system_prompt":"# Proof Soul\n\n## Identity\nYou are Proof Soul, a governed Temper agent used to verify the Pi architecture rewrite.\n\n## Instructions\n- Prefer deterministic mock runs for verification.\n- Surface memory and skills in the prompt.\n- Use tools only when the proof plan requires them.\n\n## Capabilities\n- Run sandbox tools\n- Spawn governed child agents\n- Save and recall memories\n\n## Constraints\n- Do not use destructive commands.\n- Stay inside the provided workspace.\n\n\nOverride: include the DIRECT-OVERRIDE marker.\n\n\n \n \n\n\n\n \n The proof user prefers exact verification over discussion.\n \n \n Temper Pi rewrite proof must capture SSE, session trees, cron, heartbeat, channels, and MCP.\n \n"}}
+
+event: state_change
+data: {"seq":24,"entity_type":"TemperAgent","entity_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","action":"Heartbeat","status":"Thinking","tenant":"temper-agent-proof-20260324121451"}
+
+event: llm_request_started
+data: {"tenant":"temper-agent-proof-20260324121451","entity_type":"TemperAgent","entity_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","seq":25,"kind":"llm_request_started","agent_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","tool_call_id":null,"tool_name":"llm_caller","task_id":null,"message":"calling provider=mock model=mock-proof","timestamp":"2026-03-24T12:14:56.464181+00:00","data":{"kind":"llm_request_started","message":"calling provider=mock model=mock-proof"}}
+
+event: llm_response
+data: {"tenant":"temper-agent-proof-20260324121451","entity_type":"TemperAgent","entity_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","seq":26,"kind":"llm_response","agent_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","tool_call_id":null,"tool_name":"llm_caller","task_id":null,"message":"provider returned stop_reason=end_turn","timestamp":"2026-03-24T12:14:56.464566+00:00","data":{"kind":"llm_response","message":"provider returned stop_reason=end_turn","stop_reason":"end_turn"}}
+
+event: integration_complete
+data: {"seq":27,"integration":"call_llm","module":"llm_caller","trigger_action":"HandleToolResults","result":"success","callback_action":"CheckSteering","duration_ms":141}
+
+event: state_change
+data: {"seq":28,"entity_type":"TemperAgent","entity_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","action":"CheckSteering","status":"Steering","tenant":"temper-agent-proof-20260324121451","agent_id":"system"}
+
+event: integration_start
+data: {"seq":29,"integration":"check_steering","module":"steering_checker","trigger_action":"CheckSteering"}
+
+event: integration_complete
+data: {"seq":30,"integration":"check_steering","module":"steering_checker","trigger_action":"CheckSteering","result":"success","callback_action":"ContinueWithSteering","duration_ms":33}
+
+event: state_change
+data: {"seq":31,"entity_type":"TemperAgent","entity_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","action":"ContinueWithSteering","status":"Thinking","tenant":"temper-agent-proof-20260324121451","agent_id":"system"}
+
+event: integration_start
+data: {"seq":32,"integration":"call_llm","module":"llm_caller","trigger_action":"ContinueWithSteering"}
+
+event: prompt_assembled
+data: {"tenant":"temper-agent-proof-20260324121451","entity_type":"TemperAgent","entity_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","seq":33,"kind":"prompt_assembled","agent_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","tool_call_id":null,"tool_name":"llm_caller","task_id":null,"message":"system prompt assembled","timestamp":"2026-03-24T12:14:56.657477+00:00","data":{"kind":"prompt_assembled","message":"system prompt assembled","system_prompt":"# Proof Soul\n\n## Identity\nYou are Proof Soul, a governed Temper agent used to verify the Pi architecture rewrite.\n\n## Instructions\n- Prefer deterministic mock runs for verification.\n- Surface memory and skills in the prompt.\n- Use tools only when the proof plan requires them.\n\n## Capabilities\n- Run sandbox tools\n- Spawn governed child agents\n- Save and recall memories\n\n## Constraints\n- Do not use destructive commands.\n- Stay inside the provided workspace.\n\n\nOverride: include the DIRECT-OVERRIDE marker.\n\n\n \n \n\n\n\n \n The proof user prefers exact verification over discussion.\n \n \n Temper Pi rewrite proof must capture SSE, session trees, cron, heartbeat, channels, and MCP.\n \n"}}
+
+event: state_change
+data: {"seq":34,"entity_type":"TemperAgent","entity_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","action":"Heartbeat","status":"Thinking","tenant":"temper-agent-proof-20260324121451"}
+
+event: llm_request_started
+data: {"tenant":"temper-agent-proof-20260324121451","entity_type":"TemperAgent","entity_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","seq":35,"kind":"llm_request_started","agent_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","tool_call_id":null,"tool_name":"llm_caller","task_id":null,"message":"calling provider=mock model=mock-proof","timestamp":"2026-03-24T12:14:56.668361+00:00","data":{"kind":"llm_request_started","message":"calling provider=mock model=mock-proof"}}
+
+event: llm_response
+data: {"tenant":"temper-agent-proof-20260324121451","entity_type":"TemperAgent","entity_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","seq":36,"kind":"llm_response","agent_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","tool_call_id":null,"tool_name":"llm_caller","task_id":null,"message":"provider returned stop_reason=tool_use","timestamp":"2026-03-24T12:14:56.668770+00:00","data":{"kind":"llm_response","message":"provider returned stop_reason=tool_use","stop_reason":"tool_use"}}
+
+event: integration_complete
+data: {"seq":37,"integration":"call_llm","module":"llm_caller","trigger_action":"ContinueWithSteering","result":"success","callback_action":"ProcessToolCalls","duration_ms":84}
+
+event: state_change
+data: {"seq":38,"entity_type":"TemperAgent","entity_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","action":"ProcessToolCalls","status":"Executing","tenant":"temper-agent-proof-20260324121451","agent_id":"system"}
+
+event: integration_start
+data: {"seq":39,"integration":"run_tools","module":"tool_runner","trigger_action":"ProcessToolCalls"}
+
+event: state_change
+data: {"seq":40,"entity_type":"TemperAgent","entity_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","action":"Heartbeat","status":"Executing","tenant":"temper-agent-proof-20260324121451"}
+
+event: tool_execution_start
+data: {"tenant":"temper-agent-proof-20260324121451","entity_type":"TemperAgent","entity_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","seq":41,"kind":"tool_execution_start","agent_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","tool_call_id":"mock-tool-2-0","tool_name":"save_memory","task_id":null,"message":"executing tool save_memory","timestamp":"2026-03-24T12:14:56.745453+00:00","data":{"kind":"tool_execution_start","message":"executing tool save_memory","tool_call_id":"mock-tool-2-0","tool_name":"save_memory"}}
+
+event: state_change
+data: {"seq":42,"entity_type":"TemperAgent","entity_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","action":"Heartbeat","status":"Executing","tenant":"temper-agent-proof-20260324121451"}
+
+event: tool_execution_complete
+data: {"tenant":"temper-agent-proof-20260324121451","entity_type":"TemperAgent","entity_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","seq":43,"kind":"tool_execution_complete","agent_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","tool_call_id":"mock-tool-2-0","tool_name":"save_memory","task_id":null,"message":"completed tool save_memory","timestamp":"2026-03-24T12:14:56.777472+00:00","data":{"is_error":false,"kind":"tool_execution_complete","message":"completed tool save_memory","tool_call_id":"mock-tool-2-0","tool_name":"save_memory"}}
+
+event: integration_complete
+data: {"seq":44,"integration":"run_tools","module":"tool_runner","trigger_action":"ProcessToolCalls","result":"success","callback_action":"HandleToolResults","duration_ms":106}
+
+event: state_change
+data: {"seq":45,"entity_type":"TemperAgent","entity_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","action":"HandleToolResults","status":"Thinking","tenant":"temper-agent-proof-20260324121451","agent_id":"system"}
+
+event: integration_start
+data: {"seq":46,"integration":"call_llm","module":"llm_caller","trigger_action":"HandleToolResults"}
+
+event: prompt_assembled
+data: {"tenant":"temper-agent-proof-20260324121451","entity_type":"TemperAgent","entity_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","seq":47,"kind":"prompt_assembled","agent_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","tool_call_id":null,"tool_name":"llm_caller","task_id":null,"message":"system prompt assembled","timestamp":"2026-03-24T12:14:56.876983+00:00","data":{"kind":"prompt_assembled","message":"system prompt assembled","system_prompt":"# Proof Soul\n\n## Identity\nYou are Proof Soul, a governed Temper agent used to verify the Pi architecture rewrite.\n\n## Instructions\n- Prefer deterministic mock runs for verification.\n- Surface memory and skills in the prompt.\n- Use tools only when the proof plan requires them.\n\n## Capabilities\n- Run sandbox tools\n- Spawn governed child agents\n- Save and recall memories\n\n## Constraints\n- Do not use destructive commands.\n- Stay inside the provided workspace.\n\n\nOverride: include the DIRECT-OVERRIDE marker.\n\n\n \n \n\n\n\n \n The proof user prefers exact verification over discussion.\n \n \n Temper Pi rewrite proof must capture SSE, session trees, cron, heartbeat, channels, and MCP.\n \n \n saved from direct path\n \n"}}
+
+event: state_change
+data: {"seq":48,"entity_type":"TemperAgent","entity_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","action":"Heartbeat","status":"Thinking","tenant":"temper-agent-proof-20260324121451"}
+
+event: llm_request_started
+data: {"tenant":"temper-agent-proof-20260324121451","entity_type":"TemperAgent","entity_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","seq":49,"kind":"llm_request_started","agent_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","tool_call_id":null,"tool_name":"llm_caller","task_id":null,"message":"calling provider=mock model=mock-proof","timestamp":"2026-03-24T12:14:56.886359+00:00","data":{"kind":"llm_request_started","message":"calling provider=mock model=mock-proof"}}
+
+event: llm_response
+data: {"tenant":"temper-agent-proof-20260324121451","entity_type":"TemperAgent","entity_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","seq":50,"kind":"llm_response","agent_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","tool_call_id":null,"tool_name":"llm_caller","task_id":null,"message":"provider returned stop_reason=end_turn","timestamp":"2026-03-24T12:14:56.886868+00:00","data":{"kind":"llm_response","message":"provider returned stop_reason=end_turn","stop_reason":"end_turn"}}
+
+event: integration_complete
+data: {"seq":51,"integration":"call_llm","module":"llm_caller","trigger_action":"HandleToolResults","result":"success","callback_action":"CheckSteering","duration_ms":69}
+
+event: state_change
+data: {"seq":52,"entity_type":"TemperAgent","entity_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","action":"CheckSteering","status":"Steering","tenant":"temper-agent-proof-20260324121451","agent_id":"system"}
+
+event: integration_start
+data: {"seq":53,"integration":"check_steering","module":"steering_checker","trigger_action":"CheckSteering"}
+
+event: integration_complete
+data: {"seq":54,"integration":"check_steering","module":"steering_checker","trigger_action":"CheckSteering","result":"success","callback_action":"FinalizeResult","duration_ms":11}
+
+event: state_change
+data: {"seq":55,"entity_type":"TemperAgent","entity_id":"019d1fc4-f16f-7452-9119-79ae692dc5ae","action":"FinalizeResult","status":"Completed","tenant":"temper-agent-proof-20260324121451","agent_id":"system"}
+
+event: agent_complete
+data: {"seq":56,"status":"Completed","action":"FinalizeResult","result":"Direct path finished with memory keys user-profile, project-context, proof-direct-memory.","error_message":null,"agent_id":"system","session_id":null}
+
+
+```
+
+### OTS Trajectory Summary
+```json
+{
+ "total": 837,
+ "success_count": 796,
+ "error_count": 41,
+ "success_rate": 0.951015531660693,
+ "by_action": {
+ "Activate": {
+ "total": 5,
+ "success": 5,
+ "error": 0
+ },
+ "CheckSteering": {
+ "total": 79,
+ "success": 79,
+ "error": 0
+ },
+ "CompactionComplete": {
+ "total": 4,
+ "success": 4,
+ "error": 0
+ },
+ "Configure": {
+ "total": 80,
+ "success": 75,
+ "error": 5
+ },
+ "Connect": {
+ "total": 14,
+ "success": 14,
+ "error": 0
+ },
+ "ContinueWithSteering": {
+ "total": 18,
+ "success": 18,
+ "error": 0
+ },
+ "Create": {
+ "total": 11,
+ "success": 11,
+ "error": 0
+ },
+ "CreateGovernanceDecision": {
+ "total": 38,
+ "success": 38,
+ "error": 0
+ },
+ "Fail": {
+ "total": 13,
+ "success": 10,
+ "error": 3
+ },
+ "FinalizeResult": {
+ "total": 61,
+ "success": 61,
+ "error": 0
+ },
+ "HandleToolResults": {
+ "total": 62,
+ "success": 62,
+ "error": 0
+ },
+ "Heartbeat": {
+ "total": 283,
+ "success": 259,
+ "error": 24
+ },
+ "NeedsCompaction": {
+ "total": 4,
+ "success": 4,
+ "error": 0
+ },
+ "ProcessToolCalls": {
+ "total": 62,
+ "success": 62,
+ "error": 0
+ },
+ "Provision": {
+ "total": 79,
+ "success": 75,
+ "error": 4
+ },
+ "Publish": {
+ "total": 14,
+ "success": 14,
+ "error": 0
+ },
+ "Ready": {
+ "total": 14,
+ "success": 14,
+ "error": 0
+ },
+ "ReceiveMessage": {
+ "total": 14,
+ "success": 14,
+ "error": 0
+ },
+ "ReplyDelivered": {
+ "total": 11,
+ "success": 11,
+ "error": 0
+ },
+ "RouteFailed": {
+ "total": 3,
+ "success": 3,
+ "error": 0
+ },
+ "SandboxReady": {
+ "total": 65,
+ "success": 65,
+ "error": 0
+ },
+ "Save": {
+ "total": 14,
+ "success": 11,
+ "error": 3
+ },
+ "ScanComplete": {
+ "total": 4,
+ "success": 4,
+ "error": 0
+ },
+ "ScheduleFailed": {
+ "total": 3,
+ "success": 3,
+ "error": 0
+ },
+ "SendReply": {
+ "total": 11,
+ "success": 11,
+ "error": 0
+ },
+ "Start": {
+ "total": 4,
+ "success": 4,
+ "error": 0
+ },
+ "Steer": {
+ "total": 23,
+ "success": 18,
+ "error": 5
+ },
+ "StreamUpdated": {
+ "total": 666,
+ "success": 666,
+ "error": 0
+ },
+ "TimeoutFail": {
+ "total": 4,
+ "success": 4,
+ "error": 0
+ },
+ "Trigger": {
+ "total": 9,
+ "success": 9,
+ "error": 0
+ },
+ "TriggerComplete": {
+ "total": 9,
+ "success": 9,
+ "error": 0
+ },
+ "manage_policies": {
+ "total": 2,
+ "success": 0,
+ "error": 2
+ }
+ },
+ "failed_intents": [
+ {
+ "tenant": "temper-agent-proof-20260324053709",
+ "entity_type": "TemperAgent",
+ "entity_id": "019d1e58-f3e7-7932-82cd-88058cbfb00b",
+ "action": "Fail",
+ "success": false,
+ "from_status": "Thinking",
+ "to_status": "Failed",
+ "error": "Action 'Fail' not valid from state 'Failed'",
+ "agent_id": "system",
+ "session_id": null,
+ "authz_denied": null,
+ "denied_resource": null,
+ "denied_module": null,
+ "source": "Entity",
+ "spec_governed": null,
+ "created_at": "2026-03-24T05:37:29.553394+00:00",
+ "request_body": "{\"error\":\"mock hang scenario finished without heartbeat\",\"error_message\":\"mock hang scenario finished without heartbeat\",\"integration\":\"call_llm\"}",
+ "intent": null
+ },
+ {
+ "tenant": "temper-agent-proof-20260324053613",
+ "entity_type": "TemperAgent",
+ "entity_id": "019d1e58-1c78-7fe2-a021-c9c6e1abc8bc",
+ "action": "Fail",
+ "success": false,
+ "from_status": "Thinking",
+ "to_status": "Failed",
+ "error": "Action 'Fail' not valid from state 'Failed'",
+ "agent_id": "system",
+ "session_id": null,
+ "authz_denied": null,
+ "denied_resource": null,
+ "denied_module": null,
+ "source": "Entity",
+ "spec_governed": null,
+ "created_at": "2026-03-24T05:36:34.382826+00:00",
+ "request_body": "{\"error\":\"mock hang scenario finished without heartbeat\",\"error_message\":\"mock hang scenario finished without heartbeat\",\"integration\":\"call_llm\"}",
+ "intent": null
+ },
+ {
+ "tenant": "temper-agent-proof-20260324052805",
+ "entity_type": "TemperAgent",
+ "entity_id": "019d1e51-9a6a-7422-80f0-54e98068cf18",
+ "action": "Fail",
+ "success": false,
+ "from_status": "Thinking",
+ "to_status": "Failed",
+ "error": "Action 'Fail' not valid from state 'Failed'",
+ "agent_id": "system",
+ "session_id": null,
+ "authz_denied": null,
+ "denied_resource": null,
+ "denied_module": null,
+ "source": "Entity",
+ "spec_governed": null,
+ "created_at": "2026-03-24T05:29:27.904381+00:00",
+ "request_body": "{\"error\":\"mock hang scenario finished without heartbeat\",\"error_message\":\"mock hang scenario finished without heartbeat\",\"integration\":\"call_llm\"}",
+ "intent": null
+ },
+ {
+ "tenant": "temper-agent-proof-20260324052805",
+ "entity_type": "TemperAgent",
+ "entity_id": "019d1e50-ae5f-79d3-ac83-cad10d32daff",
+ "action": "Provision",
+ "success": false,
+ "from_status": "Created",
+ "to_status": null,
+ "error": "no matching permit policy",
+ "agent_id": "anonymous",
+ "session_id": "proof-1774330097",
+ "authz_denied": true,
+ "denied_resource": "TemperAgent:019d1e50-ae5f-79d3-ac83-cad10d32daff",
+ "denied_module": null,
+ "source": "Authz",
+ "spec_governed": null,
+ "created_at": "2026-03-24T05:28:17.278573+00:00",
+ "request_body": null,
+ "intent": null
+ },
+ {
+ "tenant": "temper-agent-proof-20260324052805",
+ "entity_type": "TemperAgent",
+ "entity_id": "019d1e50-ae5f-79d3-ac83-cad10d32daff",
+ "action": "Configure",
+ "success": false,
+ "from_status": "Created",
+ "to_status": null,
+ "error": "no matching permit policy",
+ "agent_id": "anonymous",
+ "session_id": "proof-1774330097",
+ "authz_denied": true,
+ "denied_resource": "TemperAgent:019d1e50-ae5f-79d3-ac83-cad10d32daff",
+ "denied_module": null,
+ "source": "Authz",
+ "spec_governed": null,
+ "created_at": "2026-03-24T05:28:17.264156+00:00",
+ "request_body": null,
+ "intent": null
+ },
+ {
+ "tenant": "temper-agent-proof-20260324052055",
+ "entity_type": "TemperAgent",
+ "entity_id": "019d1e4a-1ae5-7cb1-b72a-14a0fa822293",
+ "action": "Provision",
+ "success": false,
+ "from_status": "Created",
+ "to_status": null,
+ "error": "no matching permit policy",
+ "agent_id": "anonymous",
+ "session_id": "proof-1774329666",
+ "authz_denied": true,
+ "denied_resource": "TemperAgent:019d1e4a-1ae5-7cb1-b72a-14a0fa822293",
+ "denied_module": null,
+ "source": "Authz",
+ "spec_governed": null,
+ "created_at": "2026-03-24T05:21:06.312690+00:00",
+ "request_body": null,
+ "intent": null
+ },
+ {
+ "tenant": "temper-agent-proof-20260324052055",
+ "entity_type": "TemperAgent",
+ "entity_id": "019d1e4a-1ae5-7cb1-b72a-14a0fa822293",
+ "action": "Configure",
+ "success": false,
+ "from_status": "Created",
+ "to_status": null,
+ "error": "no matching permit policy",
+ "agent_id": "anonymous",
+ "session_id": "proof-1774329666",
+ "authz_denied": true,
+ "denied_resource": "TemperAgent:019d1e4a-1ae5-7cb1-b72a-14a0fa822293",
+ "denied_module": null,
+ "source": "Authz",
+ "spec_governed": null,
+ "created_at": "2026-03-24T05:21:06.296204+00:00",
+ "request_body": null,
+ "intent": null
+ },
+ {
+ "tenant": "temper-agent-proof-20260324052055",
+ "entity_type": "TemperAgent",
+ "entity_id": "proof-sub-child",
+ "action": "Steer",
+ "success": false,
+ "from_status": "",
+ "to_status": "Created",
+ "error": "Action 'Steer' not valid from state 'Created'",
+ "agent_id": null,
+ "session_id": null,
+ "authz_denied": null,
+ "denied_resource": null,
+ "denied_module": null,
+ "source": "Entity",
+ "spec_governed": null,
+ "created_at": "2026-03-24T05:21:03.050500+00:00",
+ "request_body": "{\"steering_messages\":\"[{\\\"content\\\":\\\"STEERED-CHILD\\\"}]\"}",
+ "intent": null
+ },
+ {
+ "tenant": "temper-agent-proof-20260324051844",
+ "entity_type": "TemperAgent",
+ "entity_id": "019d1e49-05ad-7750-93b9-c1495350f029",
+ "action": "Provision",
+ "success": false,
+ "from_status": "Created",
+ "to_status": null,
+ "error": "no matching permit policy",
+ "agent_id": "anonymous",
+ "session_id": "proof-1774329595",
+ "authz_denied": true,
+ "denied_resource": "TemperAgent:019d1e49-05ad-7750-93b9-c1495350f029",
+ "denied_module": null,
+ "source": "Authz",
+ "spec_governed": null,
+ "created_at": "2026-03-24T05:19:55.350968+00:00",
+ "request_body": null,
+ "intent": null
+ },
+ {
+ "tenant": "temper-agent-proof-20260324051844",
+ "entity_type": "TemperAgent",
+ "entity_id": "019d1e49-05ad-7750-93b9-c1495350f029",
+ "action": "Configure",
+ "success": false,
+ "from_status": "Created",
+ "to_status": null,
+ "error": "no matching permit policy",
+ "agent_id": "anonymous",
+ "session_id": "proof-1774329595",
+ "authz_denied": true,
+ "denied_resource": "TemperAgent:019d1e49-05ad-7750-93b9-c1495350f029",
+ "denied_module": null,
+ "source": "Authz",
+ "spec_governed": null,
+ "created_at": "2026-03-24T05:19:55.329985+00:00",
+ "request_body": null,
+ "intent": null
+ },
+ {
+ "tenant": "temper-agent-proof-20260324051844",
+ "entity_type": "TemperAgent",
+ "entity_id": "019d1e48-1ac0-7d53-b740-578b822ded2d",
+ "action": "Provision",
+ "success": false,
+ "from_status": "Created",
+ "to_status": null,
+ "error": "no matching permit policy",
+ "agent_id": "anonymous",
+ "session_id": "proof-1774329535",
+ "authz_denied": true,
+ "denied_resource": "TemperAgent:019d1e48-1ac0-7d53-b740-578b822ded2d",
+ "denied_module": null,
+ "source": "Authz",
+ "spec_governed": null,
+ "created_at": "2026-03-24T05:18:55.204184+00:00",
+ "request_body": null,
+ "intent": null
+ },
+ {
+ "tenant": "temper-agent-proof-20260324051844",
+ "entity_type": "TemperAgent",
+ "entity_id": "019d1e48-1ac0-7d53-b740-578b822ded2d",
+ "action": "Configure",
+ "success": false,
+ "from_status": "Created",
+ "to_status": null,
+ "error": "no matching permit policy",
+ "agent_id": "anonymous",
+ "session_id": "proof-1774329535",
+ "authz_denied": true,
+ "denied_resource": "TemperAgent:019d1e48-1ac0-7d53-b740-578b822ded2d",
+ "denied_module": null,
+ "source": "Authz",
+ "spec_governed": null,
+ "created_at": "2026-03-24T05:18:55.187204+00:00",
+ "request_body": null,
+ "intent": null
+ },
+ {
+ "tenant": "temper-agent-proof-20260324051844",
+ "entity_type": "TemperAgent",
+ "entity_id": "proof-sub-child",
+ "action": "Steer",
+ "success": false,
+ "from_status": "",
+ "to_status": "Created",
+ "error": "Action 'Steer' not valid from state 'Created'",
+ "agent_id": null,
+ "session_id": null,
+ "authz_denied": null,
+ "denied_resource": null,
+ "denied_module": null,
+ "source": "Entity",
+ "spec_governed": null,
+ "created_at": "2026-03-24T05:18:52.071697+00:00",
+ "request_body": "{\"steering_messages\":\"[{\\\"content\\\":\\\"STEERED-CHILD\\\"}]\"}",
+ "intent": null
+ },
+ {
+ "tenant": "temper-agent-proof-20260324051726",
+ "entity_type": "TemperAgent",
+ "entity_id": "proof-sub-child",
+ "action": "Steer",
+ "success": false,
+ "from_status": "",
+ "to_status": "Created",
+ "error": "Action 'Steer' not valid from state 'Created'",
+ "agent_id": null,
+ "session_id": null,
+ "authz_denied": null,
+ "denied_resource": null,
+ "denied_module": null,
+ "source": "Entity",
+ "spec_governed": null,
+ "created_at": "2026-03-24T05:17:32.808465+00:00",
+ "request_body": "{\"steering_messages\":\"[{\\\"content\\\":\\\"STEERED-CHILD\\\"}]\"}",
+ "intent": null
+ },
+ {
+ "tenant": "temper-agent-proof-20260324051552",
+ "entity_type": "TemperAgent",
+ "entity_id": "proof-sub-child",
+ "action": "Steer",
+ "success": false,
+ "from_status": "",
+ "to_status": "Created",
+ "error": "Action 'Steer' not valid from state 'Created'",
+ "agent_id": null,
+ "session_id": null,
+ "authz_denied": null,
+ "denied_resource": null,
+ "denied_module": null,
+ "source": "Entity",
+ "spec_governed": null,
+ "created_at": "2026-03-24T05:15:58.536092+00:00",
+ "request_body": "{\"steering_messages\":\"[{\\\"content\\\":\\\"STEERED-CHILD\\\"}]\"}",
+ "intent": null
+ },
+ {
+ "tenant": "temper-agent-proof-20260324051424",
+ "entity_type": "TemperAgent",
+ "entity_id": "proof-sub-child",
+ "action": "Steer",
+ "success": false,
+ "from_status": "",
+ "to_status": "Created",
+ "error": "Action 'Steer' not valid from state 'Created'",
+ "agent_id": null,
+ "session_id": null,
+ "authz_denied": null,
+ "denied_resource": null,
+ "denied_module": null,
+ "source": "Entity",
+ "spec_governed": null,
+ "created_at": "2026-03-24T05:14:31.455917+00:00",
+ "request_body": "{\"steering_messages\":\"[{\\\"content\\\":\\\"STEERED-CHILD\\\"}]\"}",
+ "intent": null
+ },
+ {
+ "tenant": "temper-agent-proof-20260324050057",
+ "entity_type": "TemperAgent",
+ "entity_id": "019d1e37-c5fb-7c90-9e72-fd2614d747bc",
+ "action": "Configure",
+ "success": false,
+ "from_status": "Created",
+ "to_status": null,
+ "error": "no matching permit policy",
+ "agent_id": "anonymous",
+ "session_id": null,
+ "authz_denied": true,
+ "denied_resource": "TemperAgent:019d1e37-c5fb-7c90-9e72-fd2614d747bc",
+ "denied_module": null,
+ "source": "Authz",
+ "spec_governed": null,
+ "created_at": "2026-03-24T05:01:04.909880+00:00",
+ "request_body": null,
+ "intent": null
+ },
+ {
+ "tenant": "temper-agent-proof-20260324050057",
+ "entity_type": "TemperAgent",
+ "entity_id": "019d1e37-b196-74d0-aa86-2adabd01af1d",
+ "action": "Heartbeat",
+ "success": false,
+ "from_status": "Thinking",
+ "to_status": null,
+ "error": "no matching permit policy",
+ "agent_id": "anonymous",
+ "session_id": null,
+ "authz_denied": true,
+ "denied_resource": "TemperAgent:019d1e37-b196-74d0-aa86-2adabd01af1d",
+ "denied_module": null,
+ "source": "Authz",
+ "spec_governed": null,
+ "created_at": "2026-03-24T05:01:02.456299+00:00",
+ "request_body": null,
+ "intent": null
+ },
+ {
+ "tenant": "temper-agent-proof-20260324050057",
+ "entity_type": "TemperAgent",
+ "entity_id": "019d1e37-b196-74d0-aa86-2adabd01af1d",
+ "action": "Heartbeat",
+ "success": false,
+ "from_status": "Executing",
+ "to_status": null,
+ "error": "no matching permit policy",
+ "agent_id": "anonymous",
+ "session_id": null,
+ "authz_denied": true,
+ "denied_resource": "TemperAgent:019d1e37-b196-74d0-aa86-2adabd01af1d",
+ "denied_module": null,
+ "source": "Authz",
+ "spec_governed": null,
+ "created_at": "2026-03-24T05:01:02.305524+00:00",
+ "request_body": null,
+ "intent": null
+ },
+ {
+ "tenant": "temper-agent-proof-20260324050057",
+ "entity_type": "AgentMemory",
+ "entity_id": "019d1e37-bbbe-7f51-b4a0-ea126e832d58",
+ "action": "Save",
+ "success": false,
+ "from_status": "Active",
+ "to_status": null,
+ "error": "no matching permit policy",
+ "agent_id": "anonymous",
+ "session_id": null,
+ "authz_denied": true,
+ "denied_resource": "AgentMemory:019d1e37-bbbe-7f51-b4a0-ea126e832d58",
+ "denied_module": null,
+ "source": "Authz",
+ "spec_governed": null,
+ "created_at": "2026-03-24T05:01:02.290204+00:00",
+ "request_body": null,
+ "intent": null
+ }
+ ]
+}
+```
+
+### System Prompt Assembly
+```text
+# Proof Soul
+
+## Identity
+You are Proof Soul, a governed Temper agent used to verify the Pi architecture rewrite.
+
+## Instructions
+- Prefer deterministic mock runs for verification.
+- Surface memory and skills in the prompt.
+- Use tools only when the proof plan requires them.
+
+## Capabilities
+- Run sandbox tools
+- Spawn governed child agents
+- Save and recall memories
+
+## Constraints
+- Do not use destructive commands.
+- Stay inside the provided workspace.
+
+
+Override: include the DIRECT-OVERRIDE marker.
+
+
+
+
+
+
+
+
+ The proof user prefers exact verification over discussion.
+
+
+ Temper Pi rewrite proof must capture SSE, session trees, cron, heartbeat, channels, and MCP.
+
+
+```
+
+## Current Limitations
+- None observed in the proof run.
+
+## Post-Proof Code Review Fixes
+
+The following issues were identified by code review and fixed after the initial proof run:
+
+### Fix 1: Extract duplicate TemperFS helpers into `wasm-helpers` crate
+- **Issue**: `resolve_temper_api_url`, `read_session_from_temperfs`, `write_session_to_temperfs`, `entity_field_str` were duplicated across steering_checker, context_compactor, heartbeat_scan, cron_scheduler_check, and cron_trigger.
+- **Fix**: Created `os-apps/temper-agent/wasm/wasm-helpers/` shared library crate with 6 unit tests. Updated all 5 modules to import from `wasm_helpers::*` instead of duplicating.
+
+### Fix 2: Server-side filtering in route_message
+- **Issue**: `find_active_session` fetched ALL ChannelSessions then filtered in WASM memory — O(n) scan on every message.
+- **Fix**: Added `$filter=Status eq 'Active' and ChannelId eq '{channel_id}' and ThreadId eq '{thread_id}'` to the OData query, letting the server filter.
+
+### Fix 3: Real timestamp comparison in heartbeat_scan
+- **Issue**: Agents with a non-empty `last_heartbeat_at` were only logged, never compared against the timeout. Only agents with no heartbeat at all were timed out.
+- **Fix**: Added `parse_iso8601_to_epoch_secs` to `wasm-helpers` and updated heartbeat_scan to compare `now - last_heartbeat > timeout_secs`. Reference time comes from `last_scan_at` on the HeartbeatMonitor entity.
+
+### Fix 4: Allow agents to manage their own memories
+- **Issue**: `memory.cedar` restricted Save/Update/Recall to `["system", "supervisor", "human"]` agent types. Regular agents (the ones that actually need memory) were denied.
+- **Fix**: Added a permit rule: `principal.agent_type == "agent" && resource.SoulId == principal.soul_id` — agents can manage memories scoped to their own soul.
+
+## Reproduction Commands
+```bash
+python3 scripts/temper_agent_e2e_proof.py
+cargo test --workspace
+```
diff --git a/crates/temper-mcp/src/lib.rs b/crates/temper-mcp/src/lib.rs
index 8e88bbfc..cdba1a3e 100644
--- a/crates/temper-mcp/src/lib.rs
+++ b/crates/temper-mcp/src/lib.rs
@@ -22,12 +22,14 @@ pub struct McpConfig {
/// Full URL of a remote Temper server (e.g. `https://api.temper.build`).
/// Mutually exclusive with `temper_port`.
pub temper_url: Option,
- /// Agent instance ID. Resolved from the credential registry via
- /// `TEMPER_API_KEY` at startup (ADR-0033). Only used as an override
- /// when credential resolution is not available.
+ /// Optional local agent label. When `TEMPER_API_KEY` resolves through
+ /// the credential registry (ADR-0033), the verified platform-assigned
+ /// agent ID replaces this value. This field does not grant HTTP identity.
pub agent_id: Option,
- /// Agent software classification (e.g. `claude-code`). Resolved from
- /// the credential registry's `AgentType` entity at startup (ADR-0033).
+ /// Optional local agent type label (e.g. `claude-code`). When
+ /// `TEMPER_API_KEY` resolves through the credential registry, the
+ /// verified platform-assigned type replaces this value. This field does
+ /// not grant HTTP identity.
pub agent_type: Option,
/// Session ID (`X-Session-Id`). Auto-derived from `CLAUDE_SESSION_ID`.
pub session_id: Option,
diff --git a/crates/temper-mcp/src/main.rs b/crates/temper-mcp/src/main.rs
new file mode 100644
index 00000000..645c07fa
--- /dev/null
+++ b/crates/temper-mcp/src/main.rs
@@ -0,0 +1,87 @@
+use std::env;
+
+use temper_mcp::{McpConfig, run_stdio_server};
+
+fn parse_args() -> Result {
+ let mut temper_port = None;
+ let mut temper_url = None;
+ let mut agent_id = None;
+ let mut agent_type = None;
+ let mut session_id = None;
+ let mut api_key = env::var("TEMPER_API_KEY").ok();
+
+ let mut args = env::args().skip(1);
+ while let Some(arg) = args.next() {
+ match arg.as_str() {
+ "--port" => {
+ let value = args.next().ok_or("--port requires a value")?;
+ let parsed = value
+ .parse::()
+ .map_err(|_| format!("invalid --port value: {value}"))?;
+ temper_port = Some(parsed);
+ }
+ "--url" => {
+ temper_url = Some(args.next().ok_or("--url requires a value")?);
+ }
+ "--agent-id" => {
+ agent_id = Some(args.next().ok_or("--agent-id requires a value")?);
+ }
+ "--agent-type" => {
+ agent_type = Some(args.next().ok_or("--agent-type requires a value")?);
+ }
+ "--session-id" => {
+ session_id = Some(args.next().ok_or("--session-id requires a value")?);
+ }
+ "--api-key" => {
+ api_key = Some(args.next().ok_or("--api-key requires a value")?);
+ }
+ "-h" | "--help" => {
+ print_help();
+ std::process::exit(0);
+ }
+ other => {
+ return Err(format!("unknown argument: {other}"));
+ }
+ }
+ }
+
+ if temper_port.is_some() && temper_url.is_some() {
+ return Err("use either --port or --url, not both".to_string());
+ }
+ if temper_port.is_none() && temper_url.is_none() {
+ return Err("either --port or --url is required".to_string());
+ }
+
+ Ok(McpConfig {
+ temper_port,
+ temper_url,
+ agent_id,
+ agent_type,
+ session_id,
+ api_key,
+ })
+}
+
+fn print_help() {
+ eprintln!(
+ "temper-mcp\n\n\
+Usage:\n temper-mcp --port [--agent-id ] [--agent-type ] [--session-id ] [--api-key ]\n temper-mcp --url [--agent-id ] [--agent-type ] [--session-id ] [--api-key ]\n\n\
+Options:\n --port Connect to a local Temper server on 127.0.0.1:\n --url Connect to a Temper server at the given base URL\n --agent-id Optional local label; does not grant platform identity\n --agent-type Optional local type label; does not grant platform identity\n --session-id Set X-Session-Id for outbound requests\n --api-key Bearer token for API authentication (or use TEMPER_API_KEY)\n -h, --help Show this help text"
+ );
+}
+
+#[tokio::main(flavor = "current_thread")]
+async fn main() -> Result<(), Box> {
+ let config = match parse_args() {
+ Ok(config) => config,
+ Err(error) => {
+ eprintln!("{error}");
+ eprintln!();
+ print_help();
+ std::process::exit(2);
+ }
+ };
+
+ run_stdio_server(config).await?;
+ Ok(())
+}
diff --git a/crates/temper-platform/src/os_apps/mod_test.rs b/crates/temper-platform/src/os_apps/mod_test.rs
index 7f865c1c..8ba8b31e 100644
--- a/crates/temper-platform/src/os_apps/mod_test.rs
+++ b/crates/temper-platform/src/os_apps/mod_test.rs
@@ -270,7 +270,7 @@ fn test_get_skill_temper_agent() {
let bundle = get_skill("temper-agent");
assert!(bundle.is_some());
let bundle = bundle.unwrap();
- assert_eq!(bundle.specs.len(), 1);
+ assert_eq!(bundle.specs.len(), 8); // TemperAgent + AgentSoul + AgentSkill + AgentMemory + ToolHook + HeartbeatMonitor + CronJob + CronScheduler
assert!(!bundle.csdl.is_empty());
assert!(!bundle.cedar_policies.is_empty());
}
diff --git a/crates/temper-sandbox/src/repl.rs b/crates/temper-sandbox/src/repl.rs
index f642a1f2..9fdd74ff 100644
--- a/crates/temper-sandbox/src/repl.rs
+++ b/crates/temper-sandbox/src/repl.rs
@@ -15,7 +15,7 @@ use crate::runner::run_sandbox;
pub struct ReplConfig {
/// Port of the running Temper HTTP server.
pub server_port: u16,
- /// Agent ID for `X-Temper-Principal-Id` header.
+ /// Optional local label for the REPL session.
pub agent_id: Option,
}
diff --git a/crates/temper-server/src/events.rs b/crates/temper-server/src/events.rs
index ee75b8e4..560b4093 100644
--- a/crates/temper-server/src/events.rs
+++ b/crates/temper-server/src/events.rs
@@ -19,6 +19,9 @@ use crate::state::ServerState;
/// A notification emitted when an entity transitions to a new state.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct EntityStateChange {
+ /// Monotonic per-entity event sequence.
+ #[serde(default)]
+ pub seq: u64,
/// The entity type (e.g., "Order").
pub entity_type: String,
/// The entity ID.
@@ -77,6 +80,7 @@ mod tests {
#[test]
fn entity_state_change_serializes() {
let change = EntityStateChange {
+ seq: 1,
entity_type: "Order".into(),
entity_id: "o-1".into(),
action: "SubmitOrder".into(),
diff --git a/crates/temper-server/src/observe/entities.rs b/crates/temper-server/src/observe/entities.rs
index d2de1b54..e45a5314 100644
--- a/crates/temper-server/src/observe/entities.rs
+++ b/crates/temper-server/src/observe/entities.rs
@@ -156,6 +156,11 @@ pub(crate) struct WaitForEntityStateParams {
pub poll_ms: Option,
}
+#[derive(Debug, Deserialize)]
+pub(crate) struct EntityEventStreamParams {
+ pub since: Option,
+}
+
/// GET /observe/entities/{entity_type}/{entity_id}/wait -- wait for an entity to reach a target status.
#[instrument(skip_all, fields(otel.name = "GET /observe/entities/{entity_type}/{entity_id}/wait", entity_type, entity_id))]
pub(crate) async fn handle_wait_for_entity_state(
@@ -182,7 +187,7 @@ pub(crate) async fn handle_wait_for_entity_state(
let timeout_ms = params.timeout_ms.unwrap_or(120_000).clamp(1, 300_000);
let poll_ms = params.poll_ms.unwrap_or(250).clamp(10, 5_000);
- let deadline = tokio::time::Instant::now() + Duration::from_millis(timeout_ms);
+ let deadline = tokio::time::Instant::now() + Duration::from_millis(timeout_ms); // determinism-ok: HTTP handler, not actor code
loop {
let entity = state
@@ -190,7 +195,7 @@ pub(crate) async fn handle_wait_for_entity_state(
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
let status = entity.state.status.clone();
- let timed_out = tokio::time::Instant::now() >= deadline;
+ let timed_out = tokio::time::Instant::now() >= deadline; // determinism-ok: HTTP handler, not actor code
if target_statuses.contains(&status) || timed_out {
let mut json = serde_json::to_value(&entity.state)
@@ -201,10 +206,52 @@ pub(crate) async fn handle_wait_for_entity_state(
return Ok(Json(json));
}
- tokio::time::sleep(Duration::from_millis(poll_ms)).await;
+ tokio::time::sleep(Duration::from_millis(poll_ms)).await; // determinism-ok: HTTP handler, not actor code
}
}
+/// GET /observe/entities/{entity_type}/{entity_id}/events -- replayable SSE stream for one entity.
+pub(crate) async fn handle_entity_event_stream(
+ State(state): State,
+ headers: HeaderMap,
+ Path((entity_type, entity_id)): Path<(String, String)>,
+ Query(params): Query,
+) -> Result>>, StatusCode> {
+ require_observe_auth(&state, &headers, "read_events", "Entity")?;
+ let tenant = extract_tenant(&headers, &state).map_err(|(code, _)| code)?;
+ let since = params.since.unwrap_or(0);
+ let rx = state.entity_observe_tx.subscribe();
+ let replay_events = state
+ .replay_entity_observe_events(tenant.as_str(), &entity_type, &entity_id, since)
+ .into_iter()
+ .collect::>();
+ let replay_high_water = replay_events.last().map(|event| event.seq).unwrap_or(since);
+ let replay = replay_events.into_iter().map(|event| {
+ let data = serde_json::to_string(&event.data).unwrap_or_default();
+ Ok::(Event::default().event(&event.event_name).data(data))
+ });
+ let replay_stream = tokio_stream::iter(replay);
+
+ let live_tenant = tenant.clone();
+ let live_entity_type = entity_type.clone();
+ let live_entity_id = entity_id.clone();
+ let live_stream = BroadcastStream::new(rx).filter_map(move |result| match result {
+ Ok(event)
+ if event.tenant == live_tenant.as_str()
+ && event.entity_type == live_entity_type
+ && event.entity_id == live_entity_id
+ && event.seq > replay_high_water =>
+ {
+ let data = serde_json::to_string(&event.data).unwrap_or_default();
+ Some(Ok(Event::default().event(&event.event_name).data(data)))
+ }
+ Ok(_) => None,
+ Err(_) => None,
+ });
+
+ Ok(Sse::new(replay_stream.chain(live_stream)).keep_alive(KeepAlive::default()))
+}
+
/// Format entity events into the history API response shape.
fn format_history_response(
entity_type: &str,
diff --git a/crates/temper-server/src/observe/mod.rs b/crates/temper-server/src/observe/mod.rs
index fa1bd2f2..879343b7 100644
--- a/crates/temper-server/src/observe/mod.rs
+++ b/crates/temper-server/src/observe/mod.rs
@@ -160,6 +160,10 @@ pub fn build_observe_router() -> Router {
"/entities/{entity_type}/{entity_id}/wait",
get(entities::handle_wait_for_entity_state),
)
+ .route(
+ "/entities/{entity_type}/{entity_id}/events",
+ get(entities::handle_entity_event_stream),
+ )
.route("/events/stream", get(entities::handle_event_stream))
.route(
"/verification-status",
diff --git a/crates/temper-server/src/router_test.rs b/crates/temper-server/src/router_test.rs
index 6935a6e5..a9c26db1 100644
--- a/crates/temper-server/src/router_test.rs
+++ b/crates/temper-server/src/router_test.rs
@@ -589,6 +589,7 @@ async fn test_sse_events_endpoint_delivers_state_changes() {
// Send a state change event on the broadcast channel.
let _ = event_tx.send(EntityStateChange {
+ seq: 1,
entity_type: "Order".into(),
entity_id: "o-sse-1".into(),
action: "SubmitOrder".into(),
@@ -620,6 +621,7 @@ async fn test_sse_events_lagged_receiver_continues() {
// Flood it before any subscriber — then subscribe and send one more event.
for i in 0..300 {
let _ = event_tx.send(EntityStateChange {
+ seq: (i + 1) as u64,
entity_type: "Order".into(),
entity_id: format!("flood-{i}"),
action: "Flood".into(),
@@ -645,6 +647,7 @@ async fn test_sse_events_lagged_receiver_continues() {
// Send a fresh event that should be delivered.
let _ = event_tx.send(EntityStateChange {
+ seq: 301,
entity_type: "Order".into(),
entity_id: "after-flood".into(),
action: "Fresh".into(),
diff --git a/crates/temper-server/src/state/dispatch/effects.rs b/crates/temper-server/src/state/dispatch/effects.rs
index db2fc37f..72be1a37 100644
--- a/crates/temper-server/src/state/dispatch/effects.rs
+++ b/crates/temper-server/src/state/dispatch/effects.rs
@@ -107,7 +107,10 @@ impl crate::state::ServerState {
ctx: &PostDispatchContext<'_>,
response: &EntityResponse,
) {
- let _ = self.event_tx.send(EntityStateChange {
+ let seq =
+ self.next_entity_event_sequence(ctx.tenant.as_str(), ctx.entity_type, ctx.entity_id);
+ let change = EntityStateChange {
+ seq,
entity_type: ctx.entity_type.to_string(),
entity_id: ctx.entity_id.to_string(),
action: ctx.action.to_string(),
@@ -115,7 +118,55 @@ impl crate::state::ServerState {
tenant: ctx.tenant.to_string(),
agent_id: ctx.agent_ctx.agent_id.clone(),
session_id: ctx.agent_ctx.session_id.clone(),
- });
+ };
+ self.record_entity_observe_event_with_seq(
+ ctx.tenant.as_str(),
+ ctx.entity_type,
+ ctx.entity_id,
+ seq,
+ "state_change",
+ serde_json::to_value(&change).unwrap_or_default(),
+ );
+ let _ = self.event_tx.send(change);
+ if matches!(
+ response.state.status.as_str(),
+ "Completed" | "Failed" | "Cancelled"
+ ) {
+ let terminal_seq = self.next_entity_event_sequence(
+ ctx.tenant.as_str(),
+ ctx.entity_type,
+ ctx.entity_id,
+ );
+ let result = response
+ .state
+ .fields
+ .get("result")
+ .or_else(|| response.state.fields.get("Result"))
+ .and_then(serde_json::Value::as_str);
+ let error_message = response
+ .state
+ .fields
+ .get("error_message")
+ .or_else(|| response.state.fields.get("ErrorMessage"))
+ .and_then(serde_json::Value::as_str)
+ .or(response.error.as_deref());
+ self.record_entity_observe_event_with_seq(
+ ctx.tenant.as_str(),
+ ctx.entity_type,
+ ctx.entity_id,
+ terminal_seq,
+ "agent_complete",
+ serde_json::json!({
+ "seq": terminal_seq,
+ "status": response.state.status,
+ "action": ctx.action,
+ "result": result,
+ "error_message": error_message,
+ "agent_id": ctx.agent_ctx.agent_id,
+ "session_id": ctx.agent_ctx.session_id,
+ }),
+ );
+ }
let cache_key = format!("{}:{}:{}", ctx.tenant, ctx.entity_type, ctx.entity_id);
self.cache_entity_status(cache_key, response.state.status.clone());
let _ = self
diff --git a/crates/temper-server/src/state/dispatch/wasm.rs b/crates/temper-server/src/state/dispatch/wasm.rs
index fbc51e69..4c94dfb0 100644
--- a/crates/temper-server/src/state/dispatch/wasm.rs
+++ b/crates/temper-server/src/state/dispatch/wasm.rs
@@ -13,8 +13,8 @@ use temper_observe::wide_event;
use temper_runtime::scheduler::{sim_now, sim_uuid};
use temper_runtime::tenant::TenantId;
use temper_wasm::{
- AuthorizedWasmHost, ProductionWasmHost, StreamRegistry, WasmAuthzContext, WasmAuthzGate,
- WasmHost, WasmInvocationContext, WasmResourceLimits,
+ AuthorizedWasmHost, ProductionWasmHost, ProgressEmitterFn, StreamRegistry, WasmAuthzContext,
+ WasmAuthzGate, WasmHost, WasmInvocationContext, WasmResourceLimits,
};
use super::{
@@ -163,9 +163,17 @@ impl crate::state::ServerState {
.and_then(|s| s.parse::().ok())
.map(std::time::Duration::from_secs)
.unwrap_or(std::time::Duration::from_secs(30));
+ let progress_emitter = progress_emitter_fn(
+ self.clone(),
+ ctx.entity_ref.tenant.to_string(),
+ ctx.entity_ref.entity_type.to_string(),
+ ctx.entity_ref.entity_id.to_string(),
+ module_name.clone(),
+ );
let inner: Arc = Arc::new(
ProductionWasmHost::with_timeout(tenant_secrets, http_timeout)
- .with_spec_evaluator(spec_evaluator_fn()),
+ .with_spec_evaluator(spec_evaluator_fn())
+ .with_progress_emitter(progress_emitter),
);
let host: Arc = Arc::new(AuthorizedWasmHost::new(inner, gate, authz_ctx));
let max_response_bytes = integration
@@ -188,6 +196,24 @@ impl crate::state::ServerState {
hash = %hash,
"invoking WASM integration module"
);
+ let start_seq = self.next_entity_event_sequence(
+ ctx.entity_ref.tenant.as_str(),
+ ctx.entity_ref.entity_type,
+ ctx.entity_ref.entity_id,
+ );
+ self.record_entity_observe_event_with_seq(
+ ctx.entity_ref.tenant.as_str(),
+ ctx.entity_ref.entity_type,
+ ctx.entity_ref.entity_id,
+ start_seq,
+ "integration_start",
+ serde_json::json!({
+ "seq": start_seq,
+ "integration": integration.name,
+ "module": module_name,
+ "trigger_action": ctx.action,
+ }),
+ );
// --- Invoke and handle result ---
self.invoke_and_handle_result(
@@ -388,6 +414,27 @@ impl crate::state::ServerState {
.await
{
Ok(result) if result.success => {
+ let complete_seq = self.next_entity_event_sequence(
+ ctx.entity_ref.tenant.as_str(),
+ ctx.entity_ref.entity_type,
+ ctx.entity_ref.entity_id,
+ );
+ self.record_entity_observe_event_with_seq(
+ ctx.entity_ref.tenant.as_str(),
+ ctx.entity_ref.entity_type,
+ ctx.entity_ref.entity_id,
+ complete_seq,
+ "integration_complete",
+ serde_json::json!({
+ "seq": complete_seq,
+ "integration": integration.name,
+ "module": module_name,
+ "trigger_action": ctx.action,
+ "result": "success",
+ "callback_action": result.callback_action.clone(),
+ "duration_ms": result.duration_ms,
+ }),
+ );
if let Some(reason) = denial_tracker.take_denial() {
let error_str = format!("authorization denied for http_call: {reason}");
return self
@@ -437,6 +484,28 @@ impl crate::state::ServerState {
Ok(None)
}
Ok(result) => {
+ let complete_seq = self.next_entity_event_sequence(
+ ctx.entity_ref.tenant.as_str(),
+ ctx.entity_ref.entity_type,
+ ctx.entity_ref.entity_id,
+ );
+ self.record_entity_observe_event_with_seq(
+ ctx.entity_ref.tenant.as_str(),
+ ctx.entity_ref.entity_type,
+ ctx.entity_ref.entity_id,
+ complete_seq,
+ "integration_complete",
+ serde_json::json!({
+ "seq": complete_seq,
+ "integration": integration.name,
+ "module": module_name,
+ "trigger_action": ctx.action,
+ "result": "failure",
+ "callback_action": result.callback_action.clone(),
+ "duration_ms": result.duration_ms,
+ "error": result.error.clone(),
+ }),
+ );
let mut error_str = result.error.unwrap_or_else(|| {
format!(
"WASM integration '{}' returned unsuccessful result",
@@ -457,6 +526,27 @@ impl crate::state::ServerState {
.await
}
Err(e) => {
+ let complete_seq = self.next_entity_event_sequence(
+ ctx.entity_ref.tenant.as_str(),
+ ctx.entity_ref.entity_type,
+ ctx.entity_ref.entity_id,
+ );
+ self.record_entity_observe_event_with_seq(
+ ctx.entity_ref.tenant.as_str(),
+ ctx.entity_ref.entity_type,
+ ctx.entity_ref.entity_id,
+ complete_seq,
+ "integration_complete",
+ serde_json::json!({
+ "seq": complete_seq,
+ "integration": integration.name,
+ "module": module_name,
+ "trigger_action": ctx.action,
+ "result": "error",
+ "duration_ms": 0,
+ "error": e.to_string(),
+ }),
+ );
let mut error_str = e.to_string();
if let Some(reason) = denial_tracker.take_denial()
&& !error_str.contains("authorization denied for http_call")
@@ -760,8 +850,17 @@ impl crate::state::ServerState {
trigger_action: context.trigger_action.clone(),
};
let tenant_secrets = self.get_authorized_wasm_secrets(tenant, &*base_gate, &authz_ctx);
+ let progress_emitter = progress_emitter_fn(
+ self.clone(),
+ tenant.to_string(),
+ context.entity_type.clone(),
+ context.entity_id.clone(),
+ module_name.to_string(),
+ );
let inner: Arc = Arc::new(
- ProductionWasmHost::new(tenant_secrets).with_spec_evaluator(spec_evaluator_fn()),
+ ProductionWasmHost::new(tenant_secrets)
+ .with_spec_evaluator(spec_evaluator_fn())
+ .with_progress_emitter(progress_emitter),
);
let host: Arc =
Arc::new(AuthorizedWasmHost::new(inner, base_gate, authz_ctx));
@@ -819,6 +918,58 @@ fn spec_evaluator_fn() -> temper_wasm::SpecEvaluatorFn {
)
}
+fn progress_emitter_fn(
+ state: crate::state::ServerState,
+ tenant: String,
+ entity_type: String,
+ entity_id: String,
+ module_name: String,
+) -> ProgressEmitterFn {
+ std::sync::Arc::new(move |event_json: &str| {
+ let parsed = serde_json::from_str::(event_json).unwrap_or_else(|_| {
+ serde_json::json!({
+ "kind": "integration_progress",
+ "message": event_json,
+ })
+ });
+ let kind = parsed
+ .get("kind")
+ .and_then(Value::as_str)
+ .unwrap_or("integration_progress")
+ .to_string();
+ let seq = state.next_entity_event_sequence(&tenant, &entity_type, &entity_id);
+ let event = crate::state::AgentProgressEvent {
+ tenant: tenant.clone(),
+ entity_type: entity_type.clone(),
+ entity_id: entity_id.clone(),
+ seq,
+ kind,
+ agent_id: entity_id.clone(),
+ tool_call_id: parsed
+ .get("tool_call_id")
+ .and_then(Value::as_str)
+ .map(str::to_string),
+ tool_name: parsed
+ .get("tool_name")
+ .and_then(Value::as_str)
+ .map(str::to_string)
+ .or_else(|| Some(module_name.clone())),
+ task_id: parsed
+ .get("task_id")
+ .and_then(Value::as_str)
+ .map(str::to_string),
+ message: parsed
+ .get("message")
+ .and_then(Value::as_str)
+ .map(str::to_string),
+ timestamp: sim_now().to_rfc3339(),
+ data: Some(parsed),
+ };
+ state.broadcast_agent_progress(event);
+ Ok(())
+ })
+}
+
fn has_replay_trajectory_input(params: &Value) -> bool {
has_non_empty_param(params, "Trajectories") || has_non_empty_param(params, "TrajectoryActions")
}
diff --git a/crates/temper-server/src/state/entity_ops.rs b/crates/temper-server/src/state/entity_ops.rs
index c8f5b165..257c04c3 100644
--- a/crates/temper-server/src/state/entity_ops.rs
+++ b/crates/temper-server/src/state/entity_ops.rs
@@ -449,7 +449,9 @@ impl ServerState {
.map_err(|e| format!("Actor query failed: {e}"))?;
// Broadcast entity creation event for SSE subscribers
- let _ = self.event_tx.send(EntityStateChange {
+ let seq = self.next_entity_event_sequence(tenant.as_str(), entity_type, entity_id);
+ let change = EntityStateChange {
+ seq,
entity_type: entity_type.to_string(),
entity_id: entity_id.to_string(),
action: "Created".to_string(),
@@ -457,7 +459,16 @@ impl ServerState {
tenant: tenant.to_string(),
agent_id: None,
session_id: None,
- });
+ };
+ self.record_entity_observe_event_with_seq(
+ tenant.as_str(),
+ entity_type,
+ entity_id,
+ seq,
+ "state_change",
+ serde_json::to_value(&change).unwrap_or_default(),
+ );
+ let _ = self.event_tx.send(change);
Ok(response)
}
diff --git a/crates/temper-server/src/state/mod.rs b/crates/temper-server/src/state/mod.rs
index 3e786418..34dbf1f1 100644
--- a/crates/temper-server/src/state/mod.rs
+++ b/crates/temper-server/src/state/mod.rs
@@ -56,6 +56,14 @@ use temper_wasm::WasmEngine;
/// track agent activity in real time without polling.
#[derive(Debug, Clone, serde::Serialize)]
pub struct AgentProgressEvent {
+ /// Tenant that owns the related entity.
+ pub tenant: String,
+ /// Entity type that emitted the event.
+ pub entity_type: String,
+ /// Entity ID that emitted the event.
+ pub entity_id: String,
+ /// Monotonic per-entity event sequence.
+ pub seq: u64,
/// Event kind: "tool_call_started", "tool_call_completed",
/// "task_started", "task_completed", "agent_completed".
pub kind: String,
@@ -71,6 +79,26 @@ pub struct AgentProgressEvent {
pub message: Option,
/// ISO-8601 timestamp when the event was created.
pub timestamp: String,
+ /// Optional structured payload.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub data: Option,
+}
+
+/// Unified replayable event stream for a single entity.
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct EntityObserveEvent {
+ /// Tenant that owns the entity.
+ pub tenant: String,
+ /// Entity type for this event.
+ pub entity_type: String,
+ /// Entity instance ID.
+ pub entity_id: String,
+ /// Monotonic per-entity event sequence.
+ pub seq: u64,
+ /// SSE event name.
+ pub event_name: String,
+ /// Structured event payload.
+ pub data: serde_json::Value,
}
/// Lightweight hint broadcast for the Observe UI SSE refresh stream.
@@ -186,6 +214,8 @@ pub struct ServerState {
pub entity_index: Arc>>>,
/// Broadcast channel for entity state change events (SSE subscriptions).
pub event_tx: Arc>,
+ /// Broadcast channel for replayable per-entity lifecycle and progress events.
+ pub entity_observe_tx: Arc>,
/// Server start time (DST-safe: uses sim_now()).
pub start_time: chrono::DateTime,
/// Metrics collector for the /observe endpoints.
@@ -234,6 +264,10 @@ pub struct ServerState {
/// Broadcast channel for agent progress events (SSE subscriptions).
/// // determinism-ok: broadcast channel for external observation only
pub agent_progress_tx: Arc>,
+ /// Monotonic per-entity observe-event sequence counters.
+ pub entity_event_sequences: Arc>>,
+ /// Replay buffer for recent per-entity observe events.
+ pub entity_observe_log: Arc>>>,
/// Broadcast channel for observe UI refresh hints (SSE push).
/// // determinism-ok: broadcast channel for external observation only
pub observe_refresh_tx: Arc>,
@@ -272,6 +306,7 @@ impl ServerState {
}
let (event_tx, _) = tokio::sync::broadcast::channel(256); // determinism-ok: broadcast for external observation
+ let (entity_observe_tx, _) = tokio::sync::broadcast::channel(512); // determinism-ok: broadcast for external observation
let (design_time_tx, _) = tokio::sync::broadcast::channel(256); // determinism-ok: broadcast for external observation
let (pending_decision_tx, _) = tokio::sync::broadcast::channel(256); // determinism-ok: broadcast for external observation
let (agent_progress_tx, _) = tokio::sync::broadcast::channel(256); // determinism-ok: broadcast for external observation
@@ -291,6 +326,7 @@ impl ServerState {
registry: Arc::new(RwLock::new(SpecRegistry::new())),
entity_index: Arc::new(RwLock::new(BTreeMap::new())),
event_tx: Arc::new(event_tx),
+ entity_observe_tx: Arc::new(entity_observe_tx),
start_time: sim_now(),
metrics: Arc::new(MetricsCollector::new()),
record_store: Arc::new(RecordStore::new()),
@@ -315,6 +351,8 @@ impl ServerState {
tenant_policies: Arc::new(RwLock::new(BTreeMap::new())),
secrets_vault: None,
agent_progress_tx: Arc::new(agent_progress_tx), // determinism-ok: broadcast for external observation
+ entity_event_sequences: Arc::new(Mutex::new(BTreeMap::new())),
+ entity_observe_log: Arc::new(Mutex::new(BTreeMap::new())),
observe_refresh_tx: Arc::new(observe_refresh_tx), // determinism-ok: broadcast for external observation
listen_port: Arc::new(std::sync::OnceLock::new()),
single_tenant_mode: true,
@@ -346,6 +384,87 @@ impl ServerState {
}
}
+ fn push_entity_observe_event(&self, event: EntityObserveEvent) {
+ let key = format!("{}:{}:{}", event.tenant, event.entity_type, event.entity_id);
+ {
+ let mut log = self.entity_observe_log.lock().unwrap(); // ci-ok: infallible lock
+ let entries = log.entry(key).or_default();
+ entries.push(event.clone());
+ if entries.len() > 512 {
+ let overflow = entries.len().saturating_sub(512);
+ entries.drain(0..overflow);
+ }
+ }
+ let _ = self.entity_observe_tx.send(event);
+ }
+
+ pub(crate) fn next_entity_event_sequence(
+ &self,
+ tenant: &str,
+ entity_type: &str,
+ entity_id: &str,
+ ) -> u64 {
+ let key = format!("{tenant}:{entity_type}:{entity_id}");
+ let mut sequences = self.entity_event_sequences.lock().unwrap(); // ci-ok: infallible lock
+ let next = sequences.get(&key).copied().unwrap_or(0) + 1;
+ sequences.insert(key, next);
+ next
+ }
+
+ pub(crate) fn record_entity_observe_event_with_seq(
+ &self,
+ tenant: &str,
+ entity_type: &str,
+ entity_id: &str,
+ seq: u64,
+ event_name: &str,
+ data: serde_json::Value,
+ ) {
+ let event = EntityObserveEvent {
+ tenant: tenant.to_string(),
+ entity_type: entity_type.to_string(),
+ entity_id: entity_id.to_string(),
+ seq,
+ event_name: event_name.to_string(),
+ data,
+ };
+ self.push_entity_observe_event(event);
+ }
+
+ #[cfg(feature = "observe")]
+ pub(crate) fn replay_entity_observe_events(
+ &self,
+ tenant: &str,
+ entity_type: &str,
+ entity_id: &str,
+ since: u64,
+ ) -> Vec {
+ let key = format!("{tenant}:{entity_type}:{entity_id}");
+ let log = self.entity_observe_log.lock().unwrap(); // ci-ok: infallible lock
+ log.get(&key)
+ .map(|entries| {
+ entries
+ .iter()
+ .filter(|event| event.seq > since)
+ .cloned()
+ .collect()
+ })
+ .unwrap_or_default()
+ }
+
+ pub(crate) fn broadcast_agent_progress(&self, event: AgentProgressEvent) {
+ let _ = self.agent_progress_tx.send(event.clone());
+ let observe_event = EntityObserveEvent {
+ tenant: event.tenant.clone(),
+ entity_type: event.entity_type.clone(),
+ entity_id: event.entity_id.clone(),
+ seq: event.seq,
+ event_name: event.kind.clone(),
+ data: serde_json::to_value(&event).unwrap_or_default(),
+ };
+ self.push_entity_observe_event(observe_event);
+ }
+
/// Create ServerState with I/O Automaton TOML specs for transition table resolution.
///
/// Returns an error if any IOA spec fails to parse.
@@ -412,6 +531,7 @@ impl ServerState {
/// (e.g. `PlatformState`) so that writes are visible to dispatch.
pub fn from_registry_shared(system: ActorSystem, registry: Arc>) -> Self {
let (event_tx, _) = tokio::sync::broadcast::channel(256); // determinism-ok: broadcast for external observation
+ let (entity_observe_tx, _) = tokio::sync::broadcast::channel(512); // determinism-ok: broadcast for external observation
let (design_time_tx, _) = tokio::sync::broadcast::channel(256); // determinism-ok: broadcast for external observation
let (pending_decision_tx, _) = tokio::sync::broadcast::channel(256); // determinism-ok: broadcast for external observation
let (agent_progress_tx, _) = tokio::sync::broadcast::channel(256); // determinism-ok: broadcast for external observation
@@ -434,6 +554,7 @@ impl ServerState {
registry,
entity_index: Arc::new(RwLock::new(BTreeMap::new())),
event_tx: Arc::new(event_tx),
+ entity_observe_tx: Arc::new(entity_observe_tx),
start_time: sim_now(),
metrics: Arc::new(MetricsCollector::new()),
record_store: Arc::new(RecordStore::new()),
@@ -458,6 +579,8 @@ impl ServerState {
tenant_policies: Arc::new(RwLock::new(BTreeMap::new())),
secrets_vault: None,
agent_progress_tx: Arc::new(agent_progress_tx), // determinism-ok: broadcast for external observation
+ entity_event_sequences: Arc::new(Mutex::new(BTreeMap::new())),
+ entity_observe_log: Arc::new(Mutex::new(BTreeMap::new())),
observe_refresh_tx: Arc::new(observe_refresh_tx), // determinism-ok: broadcast for external observation
listen_port: Arc::new(std::sync::OnceLock::new()),
single_tenant_mode: false,
diff --git a/crates/temper-wasm-sdk/src/context.rs b/crates/temper-wasm-sdk/src/context.rs
index 49c2f776..7f70ff41 100644
--- a/crates/temper-wasm-sdk/src/context.rs
+++ b/crates/temper-wasm-sdk/src/context.rs
@@ -237,6 +237,18 @@ impl Context {
}
}
+ /// Emit a replayable progress event for the current entity.
+ pub fn emit_progress(&self, event: &Value) -> Result<(), String> {
+ let json =
+ serde_json::to_string(event).map_err(|e| format!("progress JSON serialize: {e}"))?;
+ let rc = unsafe { host::host_emit_progress(json.as_ptr() as i32, json.len() as i32) };
+ if rc == 0 {
+ Ok(())
+ } else {
+ Err("host_emit_progress failed".to_string())
+ }
+ }
+
/// Evaluate a single transition against an IOA spec via the host.
///
/// The host builds a `TransitionTable` from the IOA source and evaluates
diff --git a/crates/temper-wasm-sdk/src/host.rs b/crates/temper-wasm-sdk/src/host.rs
index a62c2e58..a28821d4 100644
--- a/crates/temper-wasm-sdk/src/host.rs
+++ b/crates/temper-wasm-sdk/src/host.rs
@@ -42,6 +42,10 @@ unsafe extern "C" {
/// Set the result JSON for this invocation.
pub fn host_set_result(ptr: i32, len: i32);
+ /// Emit a replayable progress event for the current entity.
+ /// Returns 0 on success, -1 on error.
+ pub fn host_emit_progress(ptr: i32, len: i32) -> i32;
+
/// Read a secret value by key.
/// Returns bytes written, needed size if too small, or -1 on error.
pub fn host_get_secret(key_ptr: i32, key_len: i32, buf_ptr: i32, buf_len: i32) -> i32;
diff --git a/crates/temper-wasm/src/authorized_host.rs b/crates/temper-wasm/src/authorized_host.rs
index 9afdc4ee..8698eae4 100644
--- a/crates/temper-wasm/src/authorized_host.rs
+++ b/crates/temper-wasm/src/authorized_host.rs
@@ -182,6 +182,10 @@ impl WasmHost for AuthorizedWasmHost {
self.inner
.evaluate_spec(ioa_source, current_state, action, params_json)
}
+
+ fn emit_progress(&self, event_json: &str) -> Result<(), String> {
+ self.inner.emit_progress(event_json)
+ }
}
#[cfg(test)]
diff --git a/crates/temper-wasm/src/engine/host_functions.rs b/crates/temper-wasm/src/engine/host_functions.rs
index 59bd1b55..3ba6dc2b 100644
--- a/crates/temper-wasm/src/engine/host_functions.rs
+++ b/crates/temper-wasm/src/engine/host_functions.rs
@@ -73,6 +73,31 @@ pub(super) fn link_host_functions(linker: &mut Linker) -> Result<(),
)
.map_err(|e| WasmError::Compilation(format!("failed to link host_set_result: {e}")))?;
+ // host_emit_progress(ptr, len) -> i32
+ linker
+ .func_wrap(
+ "env",
+ "host_emit_progress",
+ |mut caller: Caller<'_, HostState>, ptr: i32, len: i32| -> i32 {
+ let memory = caller.get_export("memory").and_then(|e| e.into_memory());
+ let Some(memory) = memory else {
+ return -1;
+ };
+ let mut buf = vec![0u8; len as usize];
+ if memory.read(&caller, ptr as usize, &mut buf).is_err() {
+ return -1;
+ }
+ let Ok(payload) = String::from_utf8(buf) else {
+ return -1;
+ };
+ match caller.data().host.emit_progress(&payload) {
+ Ok(()) => 0,
+ Err(_) => -1,
+ }
+ },
+ )
+ .map_err(|e| WasmError::Compilation(format!("failed to link host_emit_progress: {e}")))?;
+
// host_get_secret(key_ptr, key_len, buf_ptr, buf_len) -> actual_len (-1 on error)
linker
.func_wrap(
diff --git a/crates/temper-wasm/src/host_trait.rs b/crates/temper-wasm/src/host_trait.rs
index 3edd9a16..3d122894 100644
--- a/crates/temper-wasm/src/host_trait.rs
+++ b/crates/temper-wasm/src/host_trait.rs
@@ -79,6 +79,11 @@ pub trait WasmHost: Send + Sync {
) -> Result {
Err("evaluate_spec not supported by this host".to_string())
}
+
+ /// Emit a replayable progress event from the guest module.
+ fn emit_progress(&self, _event_json: &str) -> Result<(), String> {
+ Ok(())
+ }
}
/// Callback for evaluating IOA spec transitions.
@@ -88,6 +93,9 @@ pub trait WasmHost: Send + Sync {
pub type SpecEvaluatorFn =
Arc Result + Send + Sync>;
+/// Callback for replayable progress events emitted by guest WASM modules.
+pub type ProgressEmitterFn = Arc Result<(), String> + Send + Sync>;
+
/// Production host: real HTTP calls via reqwest, real secrets.
pub struct ProductionWasmHost {
/// HTTP client for making real requests.
@@ -96,6 +104,8 @@ pub struct ProductionWasmHost {
secrets: BTreeMap,
/// Optional spec evaluator (provided by temper-server at construction).
spec_evaluator: Option,
+ /// Optional progress emitter (provided by temper-server at construction).
+ progress_emitter: Option,
}
impl ProductionWasmHost {
@@ -114,6 +124,7 @@ impl ProductionWasmHost {
.unwrap_or_default(),
secrets,
spec_evaluator: None,
+ progress_emitter: None,
}
}
@@ -122,6 +133,12 @@ impl ProductionWasmHost {
self.spec_evaluator = Some(evaluator);
self
}
+
+ /// Create with a progress emitter for `host_emit_progress` support.
+ pub fn with_progress_emitter(mut self, emitter: ProgressEmitterFn) -> Self {
+ self.progress_emitter = Some(emitter);
+ self
+ }
}
#[async_trait]
@@ -266,6 +283,13 @@ impl WasmHost for ProductionWasmHost {
None => Err("evaluate_spec not supported by this host".to_string()),
}
}
+
+ fn emit_progress(&self, event_json: &str) -> Result<(), String> {
+ match &self.progress_emitter {
+ Some(emitter) => emitter(event_json),
+ None => Ok(()),
+ }
+ }
}
/// Parse Connect protocol binary frames from a response body.
@@ -476,6 +500,10 @@ impl WasmHost for SimWasmHost {
.cloned()
.ok_or_else(|| format!("sim: no canned response for action '{action}'"))
}
+
+ fn emit_progress(&self, _event_json: &str) -> Result<(), String> {
+ Ok(())
+ }
}
#[cfg(test)]
diff --git a/crates/temper-wasm/src/lib.rs b/crates/temper-wasm/src/lib.rs
index 0557711c..ff61631b 100644
--- a/crates/temper-wasm/src/lib.rs
+++ b/crates/temper-wasm/src/lib.rs
@@ -14,7 +14,8 @@ pub mod types;
pub use authorized_host::{AuthorizedWasmHost, WasmAuthzDecision, WasmAuthzGate, extract_domain};
pub use engine::{WasmEngine, WasmError};
pub use host_trait::{
- ProductionWasmHost, SimWasmHost, SpecEvaluatorFn, WasmHost, parse_connect_frames,
+ ProductionWasmHost, ProgressEmitterFn, SimWasmHost, SpecEvaluatorFn, WasmHost,
+ parse_connect_frames,
};
pub use stream::{StreamRegistry, StreamRegistryConfig};
pub use types::{
diff --git a/docs/adrs/0036-pi-agent-architecture.md b/docs/adrs/0036-pi-agent-architecture.md
new file mode 100644
index 00000000..ddfaa8fb
--- /dev/null
+++ b/docs/adrs/0036-pi-agent-architecture.md
@@ -0,0 +1,46 @@
+# ADR-0036: Governed Agent Architecture
+
+## Status
+
+Accepted
+
+## Context
+
+Proven open-source agent architectures already validate a useful set of patterns: append-only session trees, context compaction, a two-loop steering model, lazy skills, event streaming, and transport/channel adapters. The existing `TemperAgent` proves the basic governed loop, but it still stores flat conversation JSON, exposes only a poll-centric control plane, and keeps most capabilities inside a single agent/tool implementation boundary.
+
+We want the Temper version of that architecture, but we do not want to wrap an external agent runtime as an opaque subprocess. The Temper runtime needs each capability to remain spec-driven, Cedar-governed, observable, and verifiable.
+
+## Decision
+
+Rebase `TemperAgent` onto these proven patterns and express the missing capabilities as governed Temper specs and WASM integrations:
+
+- Session tree storage with JSONL append-only entries and branch tracking
+- Explicit compaction and steering states in the TemperAgent IOA
+- Soul, skill, memory, hook, heartbeat, and cron capabilities as first-class entities
+- SSE-based lifecycle and progress streaming for entities
+- Channel adapters and routing entities for multi-transport delivery
+- Thin tool dispatch that executes sandbox tools directly and routes entity capabilities through OData
+
+The `TemperAgent` remains the execution boundary, but the richer architecture is decomposed into separate governed entities instead of extending a monolithic match-arm tool runner.
+
+## Alternatives Considered
+
+1. Wrap an external agent runtime as a subprocess
+
+Rejected. This would preserve the interaction semantics, but the actual runtime behavior would sit outside Temper governance, Cedar authorization, and IOA verification.
+
+2. Build a new agent stack from scratch
+
+Rejected. Existing agent architectures already validate the core interaction patterns we need. Re-learning those design choices inside a brand-new implementation adds unnecessary risk.
+
+3. Extend the existing TemperAgent incrementally
+
+Chosen. This keeps the proven Temper dispatch/runtime model while migrating the storage format, state machine, event transport, and capability surface toward the target architecture.
+
+## Consequences
+
+- `TemperAgent` conversation persistence changes from flat JSON to JSONL session-tree storage.
+- New entity types are introduced in the `temper-agent` and `temper-channels` OS apps.
+- Additional WASM modules are required for compaction, steering, heartbeat scanning, cron triggering, and channel routing.
+- Event streaming becomes part of the agent contract instead of an optional side channel.
+- Capability growth shifts from tool-runner branching to governed entity composition.
diff --git a/os-apps/temper-agent/policies/agent.cedar b/os-apps/temper-agent/policies/agent.cedar
index 1dc0b314..c04e7550 100644
--- a/os-apps/temper-agent/policies/agent.cedar
+++ b/os-apps/temper-agent/policies/agent.cedar
@@ -1,8 +1,8 @@
// TemperAgent — Cedar Authorization Policies
//
// Controls who can create, configure, and interact with spec-driven agents.
-// Callback actions (SandboxReady, ProcessToolCalls, HandleToolResults, RecordResult)
-// are permitted for system agents to enable the dispatch pipeline loop.
+// Callback actions are permitted for system agents to enable the dispatch pipeline loop.
+// Steering is permitted for supervisors, humans, and parent agents.
// --- Creation and Configuration: admins, supervisors and humans ---
@@ -28,9 +28,18 @@ permit(
resource is TemperAgent
);
+// --- Steering: supervisors, humans, and system (for parent agents) ---
+
+permit(
+ principal,
+ action in [Action::"Steer"],
+ resource is TemperAgent
+) when {
+ ["supervisor", "human", "system"].contains(principal.agent_type)
+};
+
// --- Dispatch pipeline callbacks: system agents ---
// These actions are triggered by WASM integration callbacks, not by users.
-// The system agent identity drives the callback dispatch.
permit(
principal,
@@ -38,7 +47,14 @@ permit(
Action::"SandboxReady",
Action::"ProcessToolCalls",
Action::"HandleToolResults",
- Action::"RecordResult"
+ Action::"RecordResult",
+ Action::"NeedsCompaction",
+ Action::"CompactionComplete",
+ Action::"CheckSteering",
+ Action::"ContinueWithSteering",
+ Action::"FinalizeResult",
+ Action::"Heartbeat",
+ Action::"TimeoutFail"
],
resource is TemperAgent
) when {
@@ -46,13 +62,13 @@ permit(
};
// --- WASM module HTTP call authorization ---
-// Allow agent WASM modules to call the Anthropic API
+
permit(
principal is Agent,
action == Action::"http_call",
resource is HttpEndpoint
) when {
- ["sandbox_provisioner", "llm_caller", "tool_runner", "workspace_restorer"].contains(context.module)
+ ["sandbox_provisioner", "llm_caller", "tool_runner", "workspace_restorer", "context_compactor", "steering_checker", "heartbeat_scan", "heartbeat_scheduler", "cron_trigger", "cron_scheduler_check", "cron_scheduler_heartbeat"].contains(context.module)
};
// Allow agent WASM modules to access secrets
@@ -61,7 +77,7 @@ permit(
action == Action::"access_secret",
resource is Secret
) when {
- ["sandbox_provisioner", "llm_caller", "tool_runner", "workspace_restorer"].contains(context.module)
+ ["sandbox_provisioner", "llm_caller", "tool_runner", "workspace_restorer", "context_compactor"].contains(context.module)
};
// --- Failure and cancellation: supervisors, humans, and system ---
diff --git a/os-apps/temper-agent/policies/cron.cedar b/os-apps/temper-agent/policies/cron.cedar
new file mode 100644
index 00000000..62f0d57b
--- /dev/null
+++ b/os-apps/temper-agent/policies/cron.cedar
@@ -0,0 +1,54 @@
+// CronJob + CronScheduler — Cedar Authorization Policies
+
+// Admins can do everything
+permit(
+ principal is Admin,
+ action,
+ resource is CronJob
+);
+
+permit(
+ principal is Admin,
+ action,
+ resource is CronScheduler
+);
+
+// Only supervisors/humans can configure and activate cron jobs
+permit(
+ principal,
+ action in [Action::"create", Action::"Configure", Action::"Activate", Action::"Pause", Action::"Resume", Action::"Expire"],
+ resource is CronJob
+) when {
+ ["supervisor", "human"].contains(principal.agent_type)
+};
+
+// System agents can trigger cron jobs (called by CronScheduler WASM)
+permit(
+ principal,
+ action in [Action::"Trigger", Action::"TriggerComplete", Action::"TriggerFailed"],
+ resource is CronJob
+) when {
+ principal.agent_type == "system"
+};
+
+// Any authenticated agent can read cron jobs
+permit(
+ principal,
+ action in [Action::"read", Action::"list"],
+ resource is CronJob
+);
+
+// System agents manage the scheduler lifecycle
+permit(
+ principal,
+ action in [Action::"create", Action::"Start", Action::"CheckComplete", Action::"CheckFailed", Action::"ScheduledCheck", Action::"ScheduleFailed"],
+ resource is CronScheduler
+) when {
+ principal.agent_type == "system"
+};
+
+permit(
+ principal,
+ action in [Action::"read", Action::"list"],
+ resource is CronScheduler
+);
diff --git a/os-apps/temper-agent/policies/heartbeat.cedar b/os-apps/temper-agent/policies/heartbeat.cedar
new file mode 100644
index 00000000..d040be21
--- /dev/null
+++ b/os-apps/temper-agent/policies/heartbeat.cedar
@@ -0,0 +1,24 @@
+// HeartbeatMonitor — Cedar Authorization Policies
+
+// Admins can do everything
+permit(
+ principal is Admin,
+ action,
+ resource is HeartbeatMonitor
+);
+
+// System agents manage the monitor lifecycle
+permit(
+ principal,
+ action in [Action::"create", Action::"Start", Action::"ScanComplete", Action::"ScanFailed", Action::"ScheduledScan", Action::"ScheduleFailed"],
+ resource is HeartbeatMonitor
+) when {
+ principal.agent_type == "system"
+};
+
+// Any authenticated agent can read
+permit(
+ principal,
+ action in [Action::"read", Action::"list"],
+ resource is HeartbeatMonitor
+);
diff --git a/os-apps/temper-agent/policies/hooks.cedar b/os-apps/temper-agent/policies/hooks.cedar
new file mode 100644
index 00000000..ae0fd18e
--- /dev/null
+++ b/os-apps/temper-agent/policies/hooks.cedar
@@ -0,0 +1,24 @@
+// ToolHook — Cedar Authorization Policies
+
+// Admins can do everything
+permit(
+ principal is Admin,
+ action,
+ resource is ToolHook
+);
+
+// Supervisors and humans can manage hooks
+permit(
+ principal,
+ action in [Action::"create", Action::"Register", Action::"Disable", Action::"Enable"],
+ resource is ToolHook
+) when {
+ ["supervisor", "human"].contains(principal.agent_type)
+};
+
+// Any authenticated agent can read hooks (tool_runner queries them)
+permit(
+ principal,
+ action in [Action::"read", Action::"list"],
+ resource is ToolHook
+);
diff --git a/os-apps/temper-agent/policies/memory.cedar b/os-apps/temper-agent/policies/memory.cedar
new file mode 100644
index 00000000..5dc31269
--- /dev/null
+++ b/os-apps/temper-agent/policies/memory.cedar
@@ -0,0 +1,42 @@
+// AgentMemory — Cedar Authorization Policies
+
+// Admins can do everything
+permit(
+ principal is Admin,
+ action,
+ resource is AgentMemory
+);
+
+// System, supervisor, and human principals can manage memories
+permit(
+ principal,
+ action in [Action::"create", Action::"Save", Action::"Update", Action::"Recall"],
+ resource is AgentMemory
+) when {
+ ["system", "supervisor", "human"].contains(principal.agent_type)
+};
+
+// Agents can save, update, and recall memories scoped to their own soul_id
+permit(
+ principal,
+ action in [Action::"create", Action::"Save", Action::"Update", Action::"Recall"],
+ resource is AgentMemory
+) when {
+ principal.agent_type == "agent" && resource.SoulId == principal.soul_id
+};
+
+// Supervisors and humans can archive any memory
+permit(
+ principal,
+ action in [Action::"Archive"],
+ resource is AgentMemory
+) when {
+ ["supervisor", "human"].contains(principal.agent_type)
+};
+
+// Any authenticated agent can read/list memories
+permit(
+ principal,
+ action in [Action::"read", Action::"list"],
+ resource is AgentMemory
+);
diff --git a/os-apps/temper-agent/policies/skills.cedar b/os-apps/temper-agent/policies/skills.cedar
new file mode 100644
index 00000000..3ac380e6
--- /dev/null
+++ b/os-apps/temper-agent/policies/skills.cedar
@@ -0,0 +1,24 @@
+// AgentSkill — Cedar Authorization Policies
+
+// Admins can do everything
+permit(
+ principal is Admin,
+ action,
+ resource is AgentSkill
+);
+
+// Supervisors and humans can register, update, disable, enable skills
+permit(
+ principal,
+ action in [Action::"create", Action::"Register", Action::"Update", Action::"Disable", Action::"Enable"],
+ resource is AgentSkill
+) when {
+ ["supervisor", "human"].contains(principal.agent_type)
+};
+
+// Any authenticated agent can read skills
+permit(
+ principal,
+ action in [Action::"read", Action::"list"],
+ resource is AgentSkill
+);
diff --git a/os-apps/temper-agent/policies/soul.cedar b/os-apps/temper-agent/policies/soul.cedar
new file mode 100644
index 00000000..d877712a
--- /dev/null
+++ b/os-apps/temper-agent/policies/soul.cedar
@@ -0,0 +1,24 @@
+// AgentSoul — Cedar Authorization Policies
+
+// Admins can do everything
+permit(
+ principal is Admin,
+ action,
+ resource is AgentSoul
+);
+
+// Supervisors and humans can create, publish, update, archive souls
+permit(
+ principal,
+ action in [Action::"create", Action::"Create", Action::"Publish", Action::"Update", Action::"Archive"],
+ resource is AgentSoul
+) when {
+ ["supervisor", "human"].contains(principal.agent_type)
+};
+
+// Any authenticated agent can read souls
+permit(
+ principal,
+ action in [Action::"read", Action::"list"],
+ resource is AgentSoul
+);
diff --git a/os-apps/temper-agent/specs/agent_memory.ioa.toml b/os-apps/temper-agent/specs/agent_memory.ioa.toml
new file mode 100644
index 00000000..1e13da49
--- /dev/null
+++ b/os-apps/temper-agent/specs/agent_memory.ioa.toml
@@ -0,0 +1,67 @@
+# AgentMemory — Cross-session persistent knowledge.
+#
+# Memories persist ACROSS agent runs, scoped to a soul_id.
+# Types: user, feedback, project, reference (matching Claude Code taxonomy).
+# Content stored inline (memories are small).
+
+[automaton]
+name = "AgentMemory"
+states = ["Active", "Archived"]
+initial = "Active"
+
+[[state]]
+name = "key"
+type = "string"
+initial = ""
+
+[[state]]
+name = "content"
+type = "string"
+initial = ""
+
+[[state]]
+name = "memory_type"
+type = "string"
+initial = "project"
+
+[[state]]
+name = "soul_id"
+type = "string"
+initial = ""
+
+[[state]]
+name = "author_agent_id"
+type = "string"
+initial = ""
+
+[[action]]
+name = "Save"
+kind = "input"
+from = ["Active"]
+params = ["key", "content", "memory_type", "soul_id", "author_agent_id"]
+hint = "Save or initialize a memory entry."
+
+[[action]]
+name = "Update"
+kind = "input"
+from = ["Active"]
+params = ["content"]
+hint = "Update the memory content."
+
+[[action]]
+name = "Archive"
+kind = "input"
+from = ["Active"]
+to = "Archived"
+hint = "Archive the memory. It will no longer appear in agent prompts."
+
+[[action]]
+name = "Recall"
+kind = "input"
+from = ["Active"]
+hint = "Read-only recall action for audit trail. No state mutation."
+
+[[invariant]]
+name = "ArchivedIsFinal"
+when = ["Archived"]
+assert = "no_further_transitions"
diff --git a/os-apps/temper-agent/specs/agent_skill.ioa.toml b/os-apps/temper-agent/specs/agent_skill.ioa.toml
new file mode 100644
index 00000000..467f3775
--- /dev/null
+++ b/os-apps/temper-agent/specs/agent_skill.ioa.toml
@@ -0,0 +1,62 @@
+# AgentSkill — Lazy-loaded capability descriptions (SKILL.md equivalent).
+#
+# Skills define WHAT the agent can do. Only descriptions are injected into
+# the system prompt; full content loaded on demand via TemperFS read.
+
+[automaton]
+name = "AgentSkill"
+states = ["Active", "Disabled"]
+initial = "Active"
+
+[[state]]
+name = "name"
+type = "string"
+initial = ""
+
+[[state]]
+name = "description"
+type = "string"
+initial = ""
+
+[[state]]
+name = "content_file_id"
+type = "string"
+initial = ""
+
+[[state]]
+name = "scope"
+type = "string"
+initial = "global"
+
+[[state]]
+name = "agent_filter"
+type = "string"
+initial = ""
+
+[[action]]
+name = "Register"
+kind = "input"
+from = ["Active"]
+params = ["name", "description", "content_file_id", "scope", "agent_filter"]
+hint = "Register a new skill with name, description, content file, and scope."
+
+[[action]]
+name = "Disable"
+kind = "input"
+from = ["Active"]
+to = "Disabled"
+hint = "Disable the skill. It will no longer appear in agent prompts."
+
+[[action]]
+name = "Enable"
+kind = "input"
+from = ["Disabled"]
+to = "Active"
+hint = "Re-enable a disabled skill."
+
+[[action]]
+name = "Update"
+kind = "input"
+from = ["Active"]
+params = ["description", "content_file_id"]
+hint = "Update skill description or content."
diff --git a/os-apps/temper-agent/specs/agent_soul.ioa.toml b/os-apps/temper-agent/specs/agent_soul.ioa.toml
new file mode 100644
index 00000000..2f9ddfbe
--- /dev/null
+++ b/os-apps/temper-agent/specs/agent_soul.ioa.toml
@@ -0,0 +1,70 @@
+# AgentSoul — Versioned agent identity document (SOUL.md equivalent).
+#
+# A Soul defines WHO the agent is: personality, instructions, capabilities,
+# constraints. Separate from skills (WHAT) and system_prompt (per-run override).
+# Multiple agent runs can share the same Soul identity.
+
+[automaton]
+name = "AgentSoul"
+states = ["Draft", "Active", "Archived"]
+initial = "Draft"
+
+[[state]]
+name = "name"
+type = "string"
+initial = ""
+
+[[state]]
+name = "description"
+type = "string"
+initial = ""
+
+[[state]]
+name = "content_file_id"
+type = "string"
+initial = ""
+
+[[state]]
+name = "version"
+type = "counter"
+initial = "0"
+
+[[state]]
+name = "author_id"
+type = "string"
+initial = ""
+
+[[action]]
+name = "Create"
+kind = "input"
+from = ["Draft"]
+params = ["name", "description", "content_file_id", "author_id"]
+hint = "Initialize soul with identity metadata and content file reference."
+
+[[action]]
+name = "Publish"
+kind = "input"
+from = ["Draft"]
+to = "Active"
+hint = "Make the soul available for agent assignment. Only supervisors/humans."
+effect = [{ type = "increment", var = "version" }]
+
+[[action]]
+name = "Update"
+kind = "input"
+from = ["Active"]
+params = ["content_file_id", "description"]
+hint = "Update soul content. Increments version."
+effect = [{ type = "increment", var = "version" }]
+
+[[action]]
+name = "Archive"
+kind = "input"
+from = ["Active"]
+to = "Archived"
+hint = "Archive the soul. No new agents can use it."
+
+[[invariant]]
+name = "ArchivedIsFinal"
+when = ["Archived"]
+assert = "no_further_transitions"
diff --git a/os-apps/temper-agent/specs/cron_job.ioa.toml b/os-apps/temper-agent/specs/cron_job.ioa.toml
new file mode 100644
index 00000000..4c2c2d36
--- /dev/null
+++ b/os-apps/temper-agent/specs/cron_job.ioa.toml
@@ -0,0 +1,161 @@
+# CronJob — Scheduled agent runs.
+#
+# Creates and tracks TemperAgent entities on a schedule.
+# Template substitution supports {{now}}, {{run_count}}, {{last_result}}.
+
+[automaton]
+name = "CronJob"
+states = ["Created", "Active", "Paused", "Expired"]
+initial = "Created"
+
+[[state]]
+name = "name"
+type = "string"
+initial = ""
+
+[[state]]
+name = "schedule"
+type = "string"
+initial = ""
+
+[[state]]
+name = "soul_id"
+type = "string"
+initial = ""
+
+[[state]]
+name = "system_prompt"
+type = "string"
+initial = ""
+
+[[state]]
+name = "user_message_template"
+type = "string"
+initial = ""
+
+[[state]]
+name = "model"
+type = "string"
+initial = "claude-sonnet-4-20250514"
+
+[[state]]
+name = "provider"
+type = "string"
+initial = "anthropic"
+
+[[state]]
+name = "tools_enabled"
+type = "string"
+initial = "read,write,edit,bash"
+
+[[state]]
+name = "sandbox_url"
+type = "string"
+initial = ""
+
+[[state]]
+name = "max_turns"
+type = "string"
+initial = "20"
+
+[[state]]
+name = "last_run_at"
+type = "string"
+initial = ""
+
+[[state]]
+name = "next_run_at"
+type = "string"
+initial = ""
+
+[[state]]
+name = "run_count"
+type = "counter"
+initial = "0"
+
+[[state]]
+name = "max_runs"
+type = "string"
+initial = "0"
+
+[[state]]
+name = "last_agent_id"
+type = "string"
+initial = ""
+
+[[state]]
+name = "last_result"
+type = "string"
+initial = ""
+
+[[action]]
+name = "Configure"
+kind = "input"
+from = ["Created"]
+params = ["name", "schedule", "soul_id", "system_prompt", "user_message_template", "model", "provider", "tools_enabled", "sandbox_url", "max_turns", "max_runs"]
+hint = "Configure the cron job with schedule and agent parameters."
+
+[[action]]
+name = "Activate"
+kind = "input"
+from = ["Created"]
+to = "Active"
+hint = "Start the cron schedule."
+
+[[action]]
+name = "Pause"
+kind = "input"
+from = ["Active"]
+to = "Paused"
+hint = "Pause the cron schedule."
+
+[[action]]
+name = "Resume"
+kind = "input"
+from = ["Paused"]
+to = "Active"
+hint = "Resume the cron schedule."
+
+[[action]]
+name = "Trigger"
+kind = "input"
+from = ["Active"]
+params = ["last_run_at"]
+hint = "Fire the cron job — creates and provisions a TemperAgent."
+effect = [{ type = "increment", var = "run_count" }, { type = "trigger", name = "cron_trigger" }]
+
+[[action]]
+name = "TriggerComplete"
+kind = "input"
+from = ["Active"]
+params = ["last_agent_id", "last_result"]
+hint = "Callback after agent creation. Updates tracking fields."
+
+[[action]]
+name = "TriggerFailed"
+kind = "input"
+from = ["Active"]
+params = ["error_message"]
+hint = "Trigger WASM failed. Stays Active for next scheduled run."
+
+[[action]]
+name = "Expire"
+kind = "input"
+from = ["Active"]
+to = "Expired"
+hint = "Max runs reached or manually expired."
+
+[[invariant]]
+name = "ExpiredIsFinal"
+when = ["Expired"]
+assert = "no_further_transitions"
+
+[[integration]]
+name = "cron_trigger"
+trigger = "cron_trigger"
+type = "wasm"
+module = "cron_trigger"
+on_failure = "TriggerFailed"
+
+[integration.config]
+temper_api_url = "{secret:temper_api_url}"
diff --git a/os-apps/temper-agent/specs/cron_scheduler.ioa.toml b/os-apps/temper-agent/specs/cron_scheduler.ioa.toml
new file mode 100644
index 00000000..e9be074f
--- /dev/null
+++ b/os-apps/temper-agent/specs/cron_scheduler.ioa.toml
@@ -0,0 +1,84 @@
+# CronScheduler — Self-scheduling heartbeat that checks for due cron jobs.
+#
+# One per tenant. Uses HeartbeatRun pattern to periodically query
+# active CronJobs and fire Trigger on due ones.
+
+[automaton]
+name = "CronScheduler"
+states = ["Idle", "Checking"]
+initial = "Idle"
+
+[[state]]
+name = "heartbeat_interval_seconds"
+type = "string"
+initial = "60"
+
+[[state]]
+name = "last_check_at"
+type = "string"
+initial = ""
+
+[[state]]
+name = "jobs_triggered"
+type = "counter"
+initial = "0"
+
+[[action]]
+name = "Start"
+kind = "input"
+from = ["Idle"]
+to = "Checking"
+hint = "Begin checking for due cron jobs."
+effect = [{ type = "trigger", name = "check_due_jobs" }]
+
+[[action]]
+name = "CheckComplete"
+kind = "input"
+from = ["Checking"]
+to = "Idle"
+params = ["last_check_at", "jobs_triggered"]
+hint = "Check finished. Schedule next check."
+effect = [{ type = "increment", var = "jobs_triggered" }, { type = "trigger", name = "schedule_next_check" }]
+
+[[action]]
+name = "ScheduledCheck"
+kind = "input"
+from = ["Idle"]
+to = "Checking"
+hint = "Scheduled check triggered."
+effect = [{ type = "trigger", name = "check_due_jobs" }]
+
+[[action]]
+name = "CheckFailed"
+kind = "input"
+from = ["Checking"]
+to = "Idle"
+params = ["error_message"]
+hint = "Check WASM failed. Return to Idle for next scheduled check."
+
+[[action]]
+name = "ScheduleFailed"
+kind = "input"
+from = ["Idle"]
+params = ["error_message"]
+hint = "Schedule WASM failed. Stay Idle."
+
+[[integration]]
+name = "check_due_jobs"
+trigger = "check_due_jobs"
+type = "wasm"
+module = "cron_scheduler_check"
+on_failure = "CheckFailed"
+
+[integration.config]
+temper_api_url = "{secret:temper_api_url}"
+
+[[integration]]
+name = "schedule_next_check"
+trigger = "schedule_next_check"
+type = "wasm"
+module = "cron_scheduler_heartbeat"
+on_failure = "ScheduleFailed"
+
+[integration.config]
+temper_api_url = "{secret:temper_api_url}"
diff --git a/os-apps/temper-agent/specs/heartbeat_monitor.ioa.toml b/os-apps/temper-agent/specs/heartbeat_monitor.ioa.toml
new file mode 100644
index 00000000..3f4afee9
--- /dev/null
+++ b/os-apps/temper-agent/specs/heartbeat_monitor.ioa.toml
@@ -0,0 +1,84 @@
+# HeartbeatMonitor — Periodic scanner for stale agents.
+#
+# One per tenant. Self-scheduling via HeartbeatRun pattern.
+# Scans agents in non-terminal states and fires TimeoutFail on stale ones.
+
+[automaton]
+name = "HeartbeatMonitor"
+states = ["Idle", "Scanning"]
+initial = "Idle"
+
+[[state]]
+name = "scan_interval_seconds"
+type = "string"
+initial = "30"
+
+[[state]]
+name = "last_scan_at"
+type = "string"
+initial = ""
+
+[[state]]
+name = "stale_agents_found"
+type = "counter"
+initial = "0"
+
+[[action]]
+name = "Start"
+kind = "input"
+from = ["Idle"]
+to = "Scanning"
+hint = "Begin scanning for stale agents."
+effect = [{ type = "trigger", name = "scan_agents" }]
+
+[[action]]
+name = "ScanComplete"
+kind = "input"
+from = ["Scanning"]
+to = "Idle"
+params = ["last_scan_at", "stale_agents_found"]
+hint = "Scan finished. Schedule next scan."
+effect = [{ type = "increment", var = "stale_agents_found" }, { type = "trigger", name = "schedule_next_scan" }]
+
+[[action]]
+name = "ScheduledScan"
+kind = "input"
+from = ["Idle"]
+to = "Scanning"
+hint = "Scheduled scan triggered."
+effect = [{ type = "trigger", name = "scan_agents" }]
+
+[[action]]
+name = "ScanFailed"
+kind = "input"
+from = ["Scanning"]
+to = "Idle"
+params = ["error_message"]
+hint = "Scan WASM failed. Return to Idle for next scheduled scan."
+
+[[action]]
+name = "ScheduleFailed"
+kind = "input"
+from = ["Idle"]
+params = ["error_message"]
+hint = "Schedule WASM failed. Stay Idle."
+
+[[integration]]
+name = "scan_agents"
+trigger = "scan_agents"
+type = "wasm"
+module = "heartbeat_scan"
+on_failure = "ScanFailed"
+
+[integration.config]
+temper_api_url = "{secret:temper_api_url}"
+
+[[integration]]
+name = "schedule_next_scan"
+trigger = "schedule_next_scan"
+type = "wasm"
+module = "heartbeat_scheduler"
+on_failure = "ScheduleFailed"
+
+[integration.config]
+temper_api_url = "{secret:temper_api_url}"
diff --git a/os-apps/temper-agent/specs/model.csdl.xml b/os-apps/temper-agent/specs/model.csdl.xml
index 20e9c09d..03e753c4 100644
--- a/os-apps/temper-agent/specs/model.csdl.xml
+++ b/os-apps/temper-agent/specs/model.csdl.xml
@@ -22,12 +22,115 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -40,6 +143,16 @@
+
+
+
+
+
+
+
+
+
+
@@ -55,6 +168,8 @@
+
+
@@ -64,6 +179,8 @@
+
+
@@ -71,6 +188,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -80,6 +243,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -90,6 +266,8 @@
+
+
@@ -104,8 +282,228 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/os-apps/temper-agent/specs/temper_agent.ioa.toml b/os-apps/temper-agent/specs/temper_agent.ioa.toml
index 6249a711..77d1dec7 100644
--- a/os-apps/temper-agent/specs/temper_agent.ioa.toml
+++ b/os-apps/temper-agent/specs/temper_agent.ioa.toml
@@ -1,4 +1,4 @@
-# TemperAgent Entity — Spec-driven agent loop via IOA state machine.
+# TemperAgent Entity — Pi-compatible governed agent loop via IOA state machine.
#
# The agent turn cycle is expressed as state transitions with WASM integration
# triggers. No Rust while loop — the platform's dispatch pipeline drives
@@ -6,15 +6,23 @@
# Thinking → (call_llm) → ProcessToolCalls → Executing → (run_tools) →
# HandleToolResults → Thinking → ...
#
-# Conversation history stored in TemperFS (conversation_file_id FK).
+# Pi architecture additions:
+# - Session tree: JSONL append-only tree with branching (session_file_id)
+# - Compaction: Thinking → Compacting → Thinking (context_compactor WASM)
+# - Steering: Thinking → Steering → Thinking or Completed (two-loop model)
+# - Soul/Skills/Memory: entity references for identity, capabilities, knowledge
+# - Subagents: parent/child entity relationships via parent_agent_id
+# - Heartbeat: liveness monitoring via last_heartbeat_at
+#
+# Conversation history stored in TemperFS as JSONL session tree.
# Budget enforced via turn_count guard. Tools governed by Cedar.
[automaton]
name = "TemperAgent"
-states = ["Created", "Provisioning", "Thinking", "Executing", "Completed", "Failed", "Cancelled"]
+states = ["Created", "Provisioning", "Thinking", "Executing", "Compacting", "Steering", "Completed", "Failed", "Cancelled"]
initial = "Created"
-# --- State Variables ---
+# --- State Variables: Core ---
[[state]]
name = "model"
@@ -91,6 +99,11 @@ name = "sandbox_id"
type = "string"
initial = ""
+[[state]]
+name = "temper_api_url"
+type = "string"
+initial = "http://127.0.0.1:3000"
+
[[state]]
name = "file_manifest_id"
type = "string"
@@ -121,14 +134,113 @@ name = "conversation"
type = "string"
initial = ""
-# --- Actions ---
+# --- State Variables: Session Tree (Phase 2) ---
+
+[[state]]
+name = "session_file_id"
+type = "string"
+initial = ""
+
+[[state]]
+name = "session_leaf_id"
+type = "string"
+initial = ""
+
+[[state]]
+name = "context_tokens"
+type = "counter"
+initial = "0"
+
+# --- State Variables: Compaction (Phase 3) ---
+
+[[state]]
+name = "reserve_tokens"
+type = "string"
+initial = "20000"
+
+[[state]]
+name = "keep_recent_tokens"
+type = "string"
+initial = "10000"
+
+[[state]]
+name = "compaction_count"
+type = "counter"
+initial = "0"
+
+[[state]]
+name = "compaction_model"
+type = "string"
+initial = ""
+
+# --- State Variables: Steering (Phase 4) ---
+
+[[state]]
+name = "steering_messages"
+type = "string"
+initial = "[]"
+
+[[state]]
+name = "follow_up_count"
+type = "counter"
+initial = "0"
+
+[[state]]
+name = "max_follow_ups"
+type = "string"
+initial = "5"
+
+# --- State Variables: Soul / Skills / Memory (Phase 5) ---
+
+[[state]]
+name = "soul_id"
+type = "string"
+initial = ""
+
+# --- State Variables: Tool Hooks (Phase 6) ---
+
+[[state]]
+name = "hook_policy"
+type = "string"
+initial = "none"
+
+# --- State Variables: Subagents (Phase 7) ---
+
+[[state]]
+name = "parent_agent_id"
+type = "string"
+initial = ""
+
+[[state]]
+name = "child_agent_ids"
+type = "string"
+initial = "[]"
+
+[[state]]
+name = "agent_depth"
+type = "counter"
+initial = "0"
+
+# --- State Variables: Heartbeat (Phase 8) ---
+
+[[state]]
+name = "last_heartbeat_at"
+type = "string"
+initial = ""
+
+[[state]]
+name = "heartbeat_timeout_seconds"
+type = "string"
+initial = "300"
+
+# --- Actions: Core Agent Loop ---
[[action]]
name = "Configure"
kind = "input"
from = ["Created"]
-params = ["system_prompt", "user_message", "model", "provider", "max_turns", "tools_enabled", "workdir", "sandbox_url"]
-hint = "Configure agent with system prompt, user message (task), model, tool settings, and optional sandbox URL."
+params = ["system_prompt", "user_message", "model", "provider", "max_turns", "tools_enabled", "workdir", "sandbox_url", "temper_api_url", "soul_id", "parent_agent_id", "agent_depth", "max_follow_ups", "hook_policy", "reserve_tokens", "keep_recent_tokens", "compaction_model", "heartbeat_timeout_seconds"]
+hint = "Configure agent with system prompt, user message, model, tools, soul, and optional overrides."
[[action]]
name = "Provision"
@@ -143,8 +255,8 @@ name = "SandboxReady"
kind = "input"
from = ["Provisioning"]
to = "Thinking"
-params = ["sandbox_url", "sandbox_id", "workspace_id", "conversation_file_id", "file_manifest_id"]
-hint = "Callback from sandbox provisioner. Sets sandbox connection, TemperFS workspace/file/manifest, and starts think loop."
+params = ["sandbox_url", "sandbox_id", "workspace_id", "conversation_file_id", "file_manifest_id", "session_file_id", "session_leaf_id"]
+hint = "Callback from sandbox provisioner. Sets sandbox connection, TemperFS workspace/file/manifest/session, and starts think loop."
effect = [{ type = "trigger", name = "call_llm" }]
[[action]]
@@ -152,11 +264,12 @@ name = "ProcessToolCalls"
kind = "input"
from = ["Thinking"]
to = "Executing"
-params = ["pending_tool_calls", "conversation", "input_tokens", "output_tokens"]
+params = ["pending_tool_calls", "conversation", "input_tokens", "output_tokens", "session_leaf_id", "context_tokens"]
hint = "LLM returned tool_use blocks. Record token usage, transition to Executing, and run tools."
effect = [
{ type = "increment", var = "input_tokens" },
{ type = "increment", var = "output_tokens" },
+ { type = "increment", var = "context_tokens" },
{ type = "trigger", name = "run_tools" }
]
@@ -165,7 +278,7 @@ name = "HandleToolResults"
kind = "input"
from = ["Executing"]
to = "Thinking"
-params = ["pending_tool_calls", "conversation"]
+params = ["pending_tool_calls", "conversation", "session_leaf_id"]
guard = "turn_count < 100"
hint = "Tool results received. Increment turn, transition to Thinking, and call LLM again. Static safety ceiling at 100 turns; dynamic max_turns enforced by llm_caller at runtime."
effect = [
@@ -173,23 +286,119 @@ effect = [
{ type = "trigger", name = "call_llm" }
]
+# --- Actions: Compaction (Phase 3) ---
+
+[[action]]
+name = "NeedsCompaction"
+kind = "input"
+from = ["Thinking"]
+to = "Compacting"
+params = ["input_tokens", "output_tokens"]
+hint = "LLM caller detected context tokens exceeds window minus reserve. Trigger compaction."
+effect = [
+ { type = "increment", var = "input_tokens" },
+ { type = "increment", var = "output_tokens" },
+ { type = "trigger", name = "compact_context" }
+]
+
+[[action]]
+name = "CompactionComplete"
+kind = "input"
+from = ["Compacting"]
+to = "Thinking"
+params = ["session_leaf_id", "context_tokens"]
+hint = "Compaction finished. Resume LLM call with compacted context."
+effect = [
+ { type = "increment", var = "compaction_count" },
+ { type = "trigger", name = "call_llm" }
+]
+
+# --- Actions: Steering (Phase 4) ---
+
+[[action]]
+name = "CheckSteering"
+kind = "input"
+from = ["Thinking"]
+to = "Steering"
+params = ["input_tokens", "output_tokens", "session_leaf_id", "context_tokens"]
+hint = "LLM returned end_turn. Check for queued steering messages before completing."
+effect = [
+ { type = "increment", var = "input_tokens" },
+ { type = "increment", var = "output_tokens" },
+ { type = "increment", var = "context_tokens" },
+ { type = "trigger", name = "check_steering" }
+]
+
+[[action]]
+name = "ContinueWithSteering"
+kind = "input"
+from = ["Steering"]
+to = "Thinking"
+params = ["session_leaf_id", "steering_messages", "conversation"]
+guard = "follow_up_count < 100"
+hint = "Steering message found. Inject into conversation and continue. Dynamic max_follow_ups enforced by steering_checker."
+effect = [
+ { type = "increment", var = "turn_count" },
+ { type = "increment", var = "follow_up_count" },
+ { type = "trigger", name = "call_llm" }
+]
+
+[[action]]
+name = "FinalizeResult"
+kind = "input"
+from = ["Steering"]
+to = "Completed"
+params = ["result", "conversation", "session_leaf_id"]
+hint = "No steering messages queued. Set result and complete."
+effect = [
+ { type = "set_bool", var = "has_result", value = "true" }
+]
+
+[[action]]
+name = "Steer"
+kind = "input"
+from = ["Thinking", "Executing", "Steering", "Compacting"]
+params = ["steering_messages"]
+hint = "Queue a steering message for mid-run injection. External callers append messages while agent runs. Self-loop — does not change state."
+
+# --- Actions: Legacy direct completion (backward compat for max_follow_ups=0) ---
+
[[action]]
name = "RecordResult"
kind = "input"
from = ["Thinking"]
to = "Completed"
-params = ["result", "conversation", "input_tokens", "output_tokens"]
-hint = "LLM returned end_turn. Record token usage, set result, and complete."
+params = ["result", "conversation", "input_tokens", "output_tokens", "session_leaf_id"]
+hint = "LLM returned end_turn in non-steering mode (max_follow_ups=0). Record token usage, set result, and complete."
effect = [
{ type = "increment", var = "input_tokens" },
{ type = "increment", var = "output_tokens" },
{ type = "set_bool", var = "has_result", value = "true" }
]
+# --- Actions: Heartbeat (Phase 8) ---
+
+[[action]]
+name = "Heartbeat"
+kind = "input"
+from = ["Thinking", "Executing", "Steering", "Compacting"]
+params = ["last_heartbeat_at"]
+hint = "Record agent liveness. Called by WASM modules during long operations. Self-loop."
+
+[[action]]
+name = "TimeoutFail"
+kind = "input"
+from = ["Thinking", "Executing", "Steering", "Compacting"]
+to = "Failed"
+params = ["error_message"]
+hint = "Agent timed out — no heartbeat within timeout period."
+
+# --- Actions: Failure, Cancellation, Resume ---
+
[[action]]
name = "Fail"
kind = "input"
-from = ["Created", "Provisioning", "Thinking", "Executing"]
+from = ["Created", "Provisioning", "Thinking", "Executing", "Compacting", "Steering"]
to = "Failed"
params = ["error_message"]
hint = "Mark agent run as failed."
@@ -197,7 +406,7 @@ hint = "Mark agent run as failed."
[[action]]
name = "Cancel"
kind = "input"
-from = ["Created", "Provisioning", "Thinking", "Executing"]
+from = ["Created", "Provisioning", "Thinking", "Executing", "Compacting", "Steering"]
to = "Cancelled"
hint = "Cancel agent execution."
@@ -206,7 +415,7 @@ name = "Resume"
kind = "input"
from = ["Created"]
to = "Provisioning"
-params = ["sandbox_url", "sandbox_id", "workspace_id", "conversation_file_id", "file_manifest_id"]
+params = ["sandbox_url", "sandbox_id", "workspace_id", "conversation_file_id", "file_manifest_id", "session_file_id", "session_leaf_id"]
hint = "Resume agent from saved state. Transitions to Provisioning for workspace restore."
effect = [{ type = "trigger", name = "restore_workspace" }]
@@ -234,7 +443,7 @@ assert = "no_further_transitions"
[[invariant]]
name = "TurnCountNonNegative"
-when = ["Created", "Provisioning", "Thinking", "Executing", "Completed", "Failed", "Cancelled"]
+when = ["Created", "Provisioning", "Thinking", "Executing", "Compacting", "Steering", "Completed", "Failed", "Cancelled"]
assert = "turn_count >= 0"
# --- Integrations ---
@@ -284,6 +493,28 @@ sync_exclude = "__pycache__,node_modules,.git"
logfire_read_token = "{secret:logfire_read_token}"
logfire_api_base = "https://logfire-us.pydantic.dev"
+[[integration]]
+name = "compact_context"
+trigger = "compact_context"
+type = "wasm"
+module = "context_compactor"
+on_failure = "Fail"
+
+[integration.config]
+api_key = "{secret:anthropic_api_key}"
+temper_api_url = "{secret:temper_api_url}"
+timeout_secs = "120"
+
+[[integration]]
+name = "check_steering"
+trigger = "check_steering"
+type = "wasm"
+module = "steering_checker"
+on_failure = "Fail"
+
+[integration.config]
+temper_api_url = "{secret:temper_api_url}"
+
[[integration]]
name = "restore_workspace"
trigger = "restore_workspace"
diff --git a/os-apps/temper-agent/specs/tool_hook.ioa.toml b/os-apps/temper-agent/specs/tool_hook.ioa.toml
new file mode 100644
index 00000000..e0387461
--- /dev/null
+++ b/os-apps/temper-agent/specs/tool_hook.ioa.toml
@@ -0,0 +1,60 @@
+# ToolHook — Before/after hooks for tool execution.
+#
+# Hooks are evaluated by tool_runner before/after executing tools.
+# Supports block, log, and modify actions with regex tool matching.
+
+[automaton]
+name = "ToolHook"
+states = ["Active", "Disabled"]
+initial = "Active"
+
+[[state]]
+name = "name"
+type = "string"
+initial = ""
+
+[[state]]
+name = "hook_type"
+type = "string"
+initial = "before"
+
+[[state]]
+name = "tool_pattern"
+type = "string"
+initial = ".*"
+
+[[state]]
+name = "hook_action"
+type = "string"
+initial = "log"
+
+[[state]]
+name = "soul_id"
+type = "string"
+initial = ""
+
+[[state]]
+name = "priority"
+type = "counter"
+initial = "0"
+
+[[action]]
+name = "Register"
+kind = "input"
+from = ["Active"]
+params = ["name", "hook_type", "tool_pattern", "hook_action", "soul_id", "priority"]
+hint = "Register a tool hook with pattern and action."
+
+[[action]]
+name = "Disable"
+kind = "input"
+from = ["Active"]
+to = "Disabled"
+hint = "Disable the hook."
+
+[[action]]
+name = "Enable"
+kind = "input"
+from = ["Disabled"]
+to = "Active"
+hint = "Re-enable the hook."
diff --git a/os-apps/temper-agent/wasm/build.sh b/os-apps/temper-agent/wasm/build.sh
index 27575dc8..de000fc9 100755
--- a/os-apps/temper-agent/wasm/build.sh
+++ b/os-apps/temper-agent/wasm/build.sh
@@ -5,7 +5,7 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
-for module in llm_caller tool_runner sandbox_provisioner; do
+for module in llm_caller tool_runner sandbox_provisioner context_compactor steering_checker coding_agent_runner heartbeat_scan heartbeat_scheduler cron_trigger cron_scheduler_check cron_scheduler_heartbeat workspace_restorer; do
echo "Building $module..."
(cd "$SCRIPT_DIR/$module" && cargo build --target wasm32-unknown-unknown --release)
echo " -> $module built successfully"
@@ -13,7 +13,7 @@ done
echo ""
echo "All WASM modules built. Binaries at:"
-for module in llm_caller tool_runner sandbox_provisioner; do
+for module in llm_caller tool_runner sandbox_provisioner context_compactor steering_checker coding_agent_runner heartbeat_scan heartbeat_scheduler cron_trigger cron_scheduler_check cron_scheduler_heartbeat workspace_restorer; do
wasm_file="$SCRIPT_DIR/$module/target/wasm32-unknown-unknown/release/${module/-/_}.wasm"
if [ -f "$wasm_file" ]; then
size=$(wc -c < "$wasm_file" | tr -d ' ')
diff --git a/os-apps/temper-agent/wasm/coding_agent_runner/Cargo.lock b/os-apps/temper-agent/wasm/coding_agent_runner/Cargo.lock
new file mode 100644
index 00000000..e93710c8
--- /dev/null
+++ b/os-apps/temper-agent/wasm/coding_agent_runner/Cargo.lock
@@ -0,0 +1,112 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "coding-agent-runner"
+version = "0.1.0"
+dependencies = [
+ "temper-wasm-sdk",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.149"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "temper-wasm-sdk"
+version = "0.1.0"
+dependencies = [
+ "serde_json",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/os-apps/temper-agent/wasm/coding_agent_runner/Cargo.toml b/os-apps/temper-agent/wasm/coding_agent_runner/Cargo.toml
new file mode 100644
index 00000000..0df812ee
--- /dev/null
+++ b/os-apps/temper-agent/wasm/coding_agent_runner/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "coding-agent-runner"
+version = "0.1.0"
+edition = "2024"
+
+[lib]
+crate-type = ["cdylib"]
+
+[workspace]
+
+[dependencies]
+temper-wasm-sdk = { path = "../../../../crates/temper-wasm-sdk" }
diff --git a/os-apps/temper-agent/wasm/coding_agent_runner/src/lib.rs b/os-apps/temper-agent/wasm/coding_agent_runner/src/lib.rs
new file mode 100644
index 00000000..b63588d5
--- /dev/null
+++ b/os-apps/temper-agent/wasm/coding_agent_runner/src/lib.rs
@@ -0,0 +1,96 @@
+//! Coding Agent Runner — WASM module for spawning coding agent CLI processes.
+//!
+//! Maps agent_type to CLI commands and executes them in the sandbox.
+//! Supports claude-code, codex, pi, and opencode.
+
+use temper_wasm_sdk::prelude::*;
+
+#[unsafe(no_mangle)]
+pub extern "C" fn run(_ctx_ptr: i32, _ctx_len: i32) -> i32 {
+ let result = (|| -> Result<(), String> {
+ let ctx = Context::from_host()?;
+ ctx.log("info", "coding_agent_runner: starting");
+
+ let fields = ctx.entity_state.get("fields").cloned().unwrap_or(json!({}));
+ let sandbox_url = fields.get("sandbox_url").and_then(|v| v.as_str()).unwrap_or("");
+ let workdir = fields.get("workdir").and_then(|v| v.as_str()).unwrap_or("/workspace");
+
+ if sandbox_url.is_empty() {
+ return Err("coding_agent_runner: sandbox_url is empty".to_string());
+ }
+
+ // Read tool input from trigger params
+ let input = ctx.trigger_params.get("input").cloned().unwrap_or(json!({}));
+ let agent_type = input.get("agent_type").and_then(|v| v.as_str()).unwrap_or("claude-code");
+ let task = input.get("task").and_then(|v| v.as_str()).unwrap_or("");
+ let task_workdir = input.get("workdir").and_then(|v| v.as_str()).unwrap_or(workdir);
+
+ if task.is_empty() {
+ return Err("coding_agent_runner: task is empty".to_string());
+ }
+
+ // Map agent_type to CLI command
+ let command = match agent_type {
+ "claude-code" => format!("claude --permission-mode bypassPermissions --print '{}'", escape_single_quotes(task)),
+ "codex" => format!("codex exec '{}'", escape_single_quotes(task)),
+ "pi" => format!("pi -p '{}'", escape_single_quotes(task)),
+ "opencode" => format!("opencode run '{}'", escape_single_quotes(task)),
+ other => return Err(format!("coding_agent_runner: unsupported agent_type: {other}")),
+ };
+
+ ctx.log("info", &format!("coding_agent_runner: running {agent_type}: {}", &command[..command.len().min(100)]));
+
+ // Execute via sandbox bash API
+ let url = format!("{sandbox_url}/v1/processes/run");
+ let body = serde_json::to_string(&json!({
+ "command": command,
+ "workdir": task_workdir,
+ })).unwrap_or_default();
+
+ let headers = vec![("content-type".to_string(), "application/json".to_string())];
+ let resp = ctx.http_call("POST", &url, &headers, &body)?;
+
+ let output = if resp.status >= 200 && resp.status < 300 {
+ if let Ok(parsed) = serde_json::from_str::(&resp.body) {
+ let stdout = parsed.get("stdout").and_then(|v| v.as_str()).unwrap_or("");
+ let stderr = parsed.get("stderr").and_then(|v| v.as_str()).unwrap_or("");
+ let exit_code = parsed.get("exit_code").and_then(|v| v.as_i64()).unwrap_or(-1);
+ let mut out = String::new();
+ if !stdout.is_empty() { out.push_str(stdout); }
+ if !stderr.is_empty() {
+ if !out.is_empty() { out.push('\n'); }
+ out.push_str("STDERR: ");
+ out.push_str(stderr);
+ }
+ if exit_code != 0 {
+ out.push_str(&format!("\n(exit code: {exit_code})"));
+ }
+ out
+ } else {
+ resp.body
+ }
+ } else {
+ format!("Error (HTTP {}): {}", resp.status, &resp.body[..resp.body.len().min(500)])
+ };
+
+ // Return the output as a tool result
+ set_success_result("HandleToolResults", &json!({
+ "pending_tool_calls": json!([{
+ "type": "tool_result",
+ "tool_use_id": input.get("tool_use_id").and_then(|v| v.as_str()).unwrap_or("unknown"),
+ "content": output,
+ }]).to_string(),
+ }));
+
+ Ok(())
+ })();
+
+ if let Err(e) = result {
+ set_error_result(&e);
+ }
+ 0
+}
+
+fn escape_single_quotes(s: &str) -> String {
+ s.replace('\'', "'\\''")
+}
diff --git a/os-apps/temper-agent/wasm/context_compactor/Cargo.lock b/os-apps/temper-agent/wasm/context_compactor/Cargo.lock
new file mode 100644
index 00000000..b75a0fc0
--- /dev/null
+++ b/os-apps/temper-agent/wasm/context_compactor/Cargo.lock
@@ -0,0 +1,129 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "context-compactor"
+version = "0.1.0"
+dependencies = [
+ "session-tree-lib",
+ "temper-wasm-sdk",
+ "wasm-helpers",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.149"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "session-tree-lib"
+version = "0.1.0"
+dependencies = [
+ "serde_json",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "temper-wasm-sdk"
+version = "0.1.0"
+dependencies = [
+ "serde_json",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "wasm-helpers"
+version = "0.1.0"
+dependencies = [
+ "serde_json",
+ "temper-wasm-sdk",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/os-apps/temper-agent/wasm/context_compactor/Cargo.toml b/os-apps/temper-agent/wasm/context_compactor/Cargo.toml
new file mode 100644
index 00000000..5854e251
--- /dev/null
+++ b/os-apps/temper-agent/wasm/context_compactor/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "context-compactor"
+version = "0.1.0"
+edition = "2024"
+
+[lib]
+crate-type = ["cdylib"]
+
+[workspace]
+
+[dependencies]
+temper-wasm-sdk = { path = "../../../../crates/temper-wasm-sdk" }
+session-tree-lib = { path = "../session-tree-lib" }
+wasm-helpers = { path = "../wasm-helpers" }
diff --git a/os-apps/temper-agent/wasm/context_compactor/src/lib.rs b/os-apps/temper-agent/wasm/context_compactor/src/lib.rs
new file mode 100644
index 00000000..bb06248b
--- /dev/null
+++ b/os-apps/temper-agent/wasm/context_compactor/src/lib.rs
@@ -0,0 +1,231 @@
+//! Context Compactor — WASM module for compacting long agent conversations.
+//!
+//! When the session tree exceeds the context window (minus reserve_tokens),
+//! this module is triggered. It summarizes older messages using an LLM call
+//! and replaces them with a compaction entry in the session tree.
+//!
+//! Build: `cargo build --target wasm32-unknown-unknown --release`
+
+use session_tree_lib::SessionTree;
+use temper_wasm_sdk::prelude::*;
+use wasm_helpers::{read_session_from_temperfs, resolve_temper_api_url, write_session_to_temperfs};
+
+/// Entry point.
+#[unsafe(no_mangle)]
+pub extern "C" fn run(_ctx_ptr: i32, _ctx_len: i32) -> i32 {
+ let result = (|| -> Result<(), String> {
+ let ctx = Context::from_host()?;
+ ctx.log("info", "context_compactor: starting");
+
+ let fields = ctx.entity_state.get("fields").cloned().unwrap_or(json!({}));
+
+ // Read compaction parameters
+ let keep_recent_tokens: usize = fields
+ .get("keep_recent_tokens")
+ .and_then(|v| v.as_str())
+ .and_then(|s| s.parse().ok())
+ .unwrap_or(10000);
+
+ let session_file_id = fields
+ .get("session_file_id")
+ .and_then(|v| v.as_str())
+ .unwrap_or("");
+
+ let session_leaf_id = fields
+ .get("session_leaf_id")
+ .and_then(|v| v.as_str())
+ .unwrap_or("");
+
+ if session_file_id.is_empty() || session_leaf_id.is_empty() {
+ return Err("context_compactor: missing session_file_id or session_leaf_id".to_string());
+ }
+
+ let temper_api_url = resolve_temper_api_url(&ctx, &fields);
+ let tenant = &ctx.tenant;
+
+ // 1. Read session tree from TemperFS
+ let session_jsonl = read_session_from_temperfs(&ctx, &temper_api_url, tenant, session_file_id)?;
+ let mut tree = SessionTree::from_jsonl(&session_jsonl);
+
+ ctx.log("info", &format!(
+ "context_compactor: tree has {} entries, estimating tokens from leaf {}",
+ tree.len(), session_leaf_id
+ ));
+
+ // 2. Find cut point
+ let cut_point = match tree.find_cut_point(session_leaf_id, keep_recent_tokens) {
+ Some(cp) => cp,
+ None => {
+ ctx.log("warn", "context_compactor: no valid cut point found, skipping compaction");
+ set_success_result("CompactionComplete", &json!({
+ "session_leaf_id": session_leaf_id,
+ "context_tokens": tree.estimate_tokens(session_leaf_id),
+ }));
+ return Ok(());
+ }
+ };
+
+ ctx.log("info", &format!("context_compactor: cut point at entry {}", cut_point));
+
+ // 3. Build compaction prompt from messages being cut
+ let messages_to_summarize = tree.build_context(&cut_point);
+ if messages_to_summarize.is_empty() {
+ ctx.log("warn", "context_compactor: no messages to summarize");
+ set_success_result("CompactionComplete", &json!({
+ "session_leaf_id": session_leaf_id,
+ "context_tokens": tree.estimate_tokens(session_leaf_id),
+ }));
+ return Ok(());
+ }
+
+ let conversation_text = format_messages_for_summary(&messages_to_summarize);
+
+ // 4. Call LLM for structured summary
+ let compaction_model = fields
+ .get("compaction_model")
+ .and_then(|v| v.as_str())
+ .filter(|s| !s.is_empty())
+ .unwrap_or_else(|| {
+ fields.get("model").and_then(|v| v.as_str()).unwrap_or("claude-sonnet-4-20250514")
+ });
+
+ let api_key = ctx.config.get("api_key").cloned().unwrap_or_default();
+ let provider = fields
+ .get("provider")
+ .and_then(|v| v.as_str())
+ .unwrap_or("anthropic");
+ let summary = if provider.eq_ignore_ascii_case("mock") || api_key.trim().is_empty() {
+ build_mock_summary(&conversation_text)
+ } else {
+ call_compaction_llm(&ctx, &api_key, compaction_model, &conversation_text)?
+ };
+
+ ctx.log("info", &format!(
+ "context_compactor: generated summary ({} chars)",
+ summary.len()
+ ));
+
+ // 5. Append compaction entry to session tree
+ let (compaction_id, _line) = tree.append_compaction(session_leaf_id, &summary, &cut_point);
+
+ // 6. Write updated session tree back to TemperFS
+ let updated_jsonl = tree.to_jsonl();
+ write_session_to_temperfs(&ctx, &temper_api_url, tenant, session_file_id, &updated_jsonl)?;
+
+ // 7. Return CompactionComplete with new leaf pointing after compaction
+ let new_token_estimate = tree.estimate_tokens(&compaction_id);
+ set_success_result("CompactionComplete", &json!({
+ "session_leaf_id": compaction_id,
+ "context_tokens": new_token_estimate,
+ }));
+
+ Ok(())
+ })();
+
+ if let Err(e) = result {
+ set_error_result(&e);
+ }
+ 0
+}
+
+fn build_mock_summary(conversation_text: &str) -> String {
+ let truncated: String = conversation_text.chars().take(600).collect();
+ format!(
+ "## Goal\nPreserve the active task.\n\n## Constraints & Preferences\nStay within the current workspace and existing agent context.\n\n## Progress\n- Done: Earlier conversation was compacted.\n- In Progress: Continue the active task with the remaining context.\n- Blocked: None.\n\n## Key Decisions\nUse the deterministic mock compaction path when no real model is configured.\n\n## Next Steps\nResume the agent loop after compaction.\n\n## Critical Context\n{}",
+ truncated
+ )
+}
+
+/// Format messages into a text block for the compaction LLM prompt.
+fn format_messages_for_summary(messages: &[Value]) -> String {
+ let mut text = String::new();
+ for msg in messages {
+ let role = msg.get("role").and_then(|v| v.as_str()).unwrap_or("unknown");
+ let content = msg.get("content").cloned().unwrap_or(json!(""));
+ let content_str = match content {
+ Value::String(s) => s,
+ Value::Array(arr) => {
+ arr.iter()
+ .filter_map(|block| {
+ if block.get("type").and_then(|v| v.as_str()) == Some("text") {
+ block.get("text").and_then(|v| v.as_str()).map(String::from)
+ } else if block.get("type").and_then(|v| v.as_str()) == Some("tool_use") {
+ Some(format!("[tool_use: {}]", block.get("name").and_then(|v| v.as_str()).unwrap_or("unknown")))
+ } else if block.get("type").and_then(|v| v.as_str()) == Some("tool_result") {
+ let content = block.get("content").and_then(|v| v.as_str()).unwrap_or("...");
+ let truncated = if content.len() > 200 { &content[..200] } else { content };
+ Some(format!("[tool_result: {}]", truncated))
+ } else {
+ None
+ }
+ })
+ .collect::>()
+ .join("\n")
+ }
+ _ => serde_json::to_string(&content).unwrap_or_default(),
+ };
+ text.push_str(&format!("## {role}\n{content_str}\n\n"));
+ }
+ text
+}
+
+/// Call the LLM with a compaction-specific system prompt.
+fn call_compaction_llm(
+ ctx: &Context,
+ api_key: &str,
+ model: &str,
+ conversation_text: &str,
+) -> Result {
+ let system_prompt = "You are a conversation compactor. Summarize the following conversation into a structured summary. Be concise but preserve all important context, decisions, and progress. Output the summary in this exact format:\n\n## Goal\n\n\n## Constraints & Preferences\n\n\n## Progress\n- Done: \n- In Progress: \n- Blocked: \n\n## Key Decisions\n\n\n## Next Steps\n\n\n## Critical Context\n";
+
+ let body = json!({
+ "model": model,
+ "max_tokens": 2048,
+ "system": system_prompt,
+ "messages": [{
+ "role": "user",
+ "content": format!("Summarize this conversation:\n\n{conversation_text}")
+ }]
+ });
+
+ let is_oauth = api_key.contains("sk-ant-oat");
+ let headers = if is_oauth {
+ vec![
+ ("authorization".to_string(), format!("Bearer {api_key}")),
+ ("anthropic-version".to_string(), "2023-06-01".to_string()),
+ ("anthropic-beta".to_string(), "oauth-2025-04-20".to_string()),
+ ("content-type".to_string(), "application/json".to_string()),
+ ]
+ } else {
+ vec![
+ ("x-api-key".to_string(), api_key.to_string()),
+ ("anthropic-version".to_string(), "2023-06-01".to_string()),
+ ("content-type".to_string(), "application/json".to_string()),
+ ]
+ };
+
+ let body_str = serde_json::to_string(&body).map_err(|e| format!("JSON serialize error: {e}"))?;
+
+ let resp = ctx.http_call("POST", "https://api.anthropic.com/v1/messages", &headers, &body_str)?;
+ if resp.status != 200 {
+ return Err(format!(
+ "Compaction LLM call failed (HTTP {}): {}",
+ resp.status,
+ &resp.body[..resp.body.len().min(500)]
+ ));
+ }
+
+ let parsed: Value = serde_json::from_str(&resp.body)
+ .map_err(|e| format!("failed to parse compaction LLM response: {e}"))?;
+
+ // Extract text from response
+ let text = parsed
+ .get("content")
+ .and_then(|v| v.as_array())
+ .and_then(|arr| arr.iter().find(|b| b.get("type").and_then(|v| v.as_str()) == Some("text")))
+ .and_then(|b| b.get("text").and_then(|v| v.as_str()))
+ .unwrap_or("Summary unavailable")
+ .to_string();
+
+ Ok(text)
+}
diff --git a/os-apps/temper-agent/wasm/cron_scheduler_check/Cargo.lock b/os-apps/temper-agent/wasm/cron_scheduler_check/Cargo.lock
new file mode 100644
index 00000000..01b5744f
--- /dev/null
+++ b/os-apps/temper-agent/wasm/cron_scheduler_check/Cargo.lock
@@ -0,0 +1,121 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "cron-scheduler-check"
+version = "0.1.0"
+dependencies = [
+ "temper-wasm-sdk",
+ "wasm-helpers",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.149"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "temper-wasm-sdk"
+version = "0.1.0"
+dependencies = [
+ "serde_json",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "wasm-helpers"
+version = "0.1.0"
+dependencies = [
+ "serde_json",
+ "temper-wasm-sdk",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/os-apps/temper-agent/wasm/cron_scheduler_check/Cargo.toml b/os-apps/temper-agent/wasm/cron_scheduler_check/Cargo.toml
new file mode 100644
index 00000000..70520692
--- /dev/null
+++ b/os-apps/temper-agent/wasm/cron_scheduler_check/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "cron-scheduler-check"
+version = "0.1.0"
+edition = "2024"
+
+[lib]
+crate-type = ["cdylib"]
+
+[workspace]
+
+[dependencies]
+temper-wasm-sdk = { path = "../../../../crates/temper-wasm-sdk" }
+wasm-helpers = { path = "../wasm-helpers" }
diff --git a/os-apps/temper-agent/wasm/cron_scheduler_check/src/lib.rs b/os-apps/temper-agent/wasm/cron_scheduler_check/src/lib.rs
new file mode 100644
index 00000000..f51f8c76
--- /dev/null
+++ b/os-apps/temper-agent/wasm/cron_scheduler_check/src/lib.rs
@@ -0,0 +1,74 @@
+//! Cron Scheduler Check — WASM module for checking due cron jobs.
+//!
+//! Queries active CronJobs where NextRunAt <= now and fires Trigger on each.
+
+use temper_wasm_sdk::prelude::*;
+use wasm_helpers::{entity_field_str, resolve_temper_api_url};
+
+#[unsafe(no_mangle)]
+pub extern "C" fn run(_ctx_ptr: i32, _ctx_len: i32) -> i32 {
+ let result = (|| -> Result<(), String> {
+ let ctx = Context::from_host()?;
+ ctx.log("info", "cron_scheduler_check: starting");
+
+ let fields = ctx.entity_state.get("fields").cloned().unwrap_or(json!({}));
+ let temper_api_url = resolve_temper_api_url(&ctx, &fields);
+ let tenant = &ctx.tenant;
+
+ let headers = vec![
+ ("content-type".to_string(), "application/json".to_string()),
+ ("x-tenant-id".to_string(), tenant.to_string()),
+ ("x-temper-principal-kind".to_string(), "admin".to_string()),
+ ("accept".to_string(), "application/json".to_string()),
+ ];
+
+ // Query active cron jobs
+ let url = format!("{temper_api_url}/tdata/CronJobs?$filter=Status eq 'Active'");
+ let resp = ctx.http_call("GET", &url, &headers, "")?;
+
+ let mut triggered_count: i64 = 0;
+
+ if resp.status == 200 {
+ let parsed: Value = serde_json::from_str(&resp.body).unwrap_or(json!({"value": []}));
+ let jobs = parsed.get("value").and_then(|v| v.as_array()).cloned().unwrap_or_default();
+
+ ctx.log("info", &format!("cron_scheduler_check: found {} active cron jobs", jobs.len()));
+
+ for job in &jobs {
+ let job_id = job
+ .get("entity_id")
+ .and_then(|v| v.as_str())
+ .or_else(|| entity_field_str(job, &["Id"]))
+ .unwrap_or("");
+ // NextRunAt check deferred to cron_scheduler — this module triggers all active jobs
+ // that the scheduler determined are due
+ let trigger_url = format!("{temper_api_url}/tdata/CronJobs('{job_id}')/Temper.Agent.Trigger");
+ let trigger_body = json!({ "last_run_at": "" });
+ match ctx.http_call("POST", &trigger_url, &headers, &trigger_body.to_string()) {
+ Ok(r) if r.status >= 200 && r.status < 300 => {
+ triggered_count += 1;
+ ctx.log("info", &format!("cron_scheduler_check: triggered job {}", job_id));
+ }
+ Ok(r) => {
+ ctx.log("warn", &format!("cron_scheduler_check: failed to trigger job {} (HTTP {})", job_id, r.status));
+ }
+ Err(e) => {
+ ctx.log("warn", &format!("cron_scheduler_check: failed to trigger job {}: {}", job_id, e));
+ }
+ }
+ }
+ }
+
+ set_success_result("CheckComplete", &json!({
+ "last_check_at": "",
+ "jobs_triggered": triggered_count,
+ }));
+
+ Ok(())
+ })();
+
+ if let Err(e) = result {
+ set_error_result(&e);
+ }
+ 0
+}
diff --git a/os-apps/temper-agent/wasm/cron_scheduler_heartbeat/Cargo.lock b/os-apps/temper-agent/wasm/cron_scheduler_heartbeat/Cargo.lock
new file mode 100644
index 00000000..c72b6c1f
--- /dev/null
+++ b/os-apps/temper-agent/wasm/cron_scheduler_heartbeat/Cargo.lock
@@ -0,0 +1,121 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "cron-scheduler-heartbeat"
+version = "0.1.0"
+dependencies = [
+ "temper-wasm-sdk",
+ "wasm-helpers",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.149"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "temper-wasm-sdk"
+version = "0.1.0"
+dependencies = [
+ "serde_json",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "wasm-helpers"
+version = "0.1.0"
+dependencies = [
+ "serde_json",
+ "temper-wasm-sdk",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/os-apps/temper-agent/wasm/cron_scheduler_heartbeat/Cargo.toml b/os-apps/temper-agent/wasm/cron_scheduler_heartbeat/Cargo.toml
new file mode 100644
index 00000000..80708a1f
--- /dev/null
+++ b/os-apps/temper-agent/wasm/cron_scheduler_heartbeat/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "cron-scheduler-heartbeat"
+version = "0.1.0"
+edition = "2024"
+
+[lib]
+crate-type = ["cdylib"]
+
+[workspace]
+
+[dependencies]
+temper-wasm-sdk = { path = "../../../../crates/temper-wasm-sdk" }
+wasm-helpers = { path = "../wasm-helpers" }
diff --git a/os-apps/temper-agent/wasm/cron_scheduler_heartbeat/src/lib.rs b/os-apps/temper-agent/wasm/cron_scheduler_heartbeat/src/lib.rs
new file mode 100644
index 00000000..435f7e16
--- /dev/null
+++ b/os-apps/temper-agent/wasm/cron_scheduler_heartbeat/src/lib.rs
@@ -0,0 +1,48 @@
+use temper_wasm_sdk::prelude::*;
+use wasm_helpers::resolve_temper_api_url;
+
+#[unsafe(no_mangle)]
+pub extern "C" fn run(_ctx_ptr: i32, _ctx_len: i32) -> i32 {
+ let result = (|| -> Result<(), String> {
+ let ctx = Context::from_host()?;
+ let fields = ctx.entity_state.get("fields").cloned().unwrap_or_else(|| json!({}));
+ let interval_seconds = fields
+ .get("heartbeat_interval_seconds")
+ .and_then(|v| v.as_str())
+ .and_then(|v| v.parse::().ok())
+ .unwrap_or(60)
+ .clamp(1, 300);
+ let base_url = resolve_temper_api_url(&ctx, &fields);
+ let headers = vec![
+ ("x-tenant-id".to_string(), ctx.tenant.clone()),
+ ("x-temper-principal-kind".to_string(), "admin".to_string()),
+ ("accept".to_string(), "application/json".to_string()),
+ ("content-type".to_string(), "application/json".to_string()),
+ ];
+
+ let wait_url = format!(
+ "{base_url}/observe/entities/{}/{}/wait?statuses=__never__&timeout_ms={}&poll_ms=250",
+ ctx.entity_type,
+ ctx.entity_id,
+ interval_seconds * 1000
+ );
+ let _ = ctx.http_call("GET", &wait_url, &headers, "")?;
+
+ let action_url = format!(
+ "{base_url}/tdata/CronSchedulers('{}')/Temper.Agent.CronScheduler.ScheduledCheck",
+ ctx.entity_id
+ );
+ let _ = ctx.http_call("POST", &action_url, &headers, "{}")?;
+
+ set_success_result("ScheduleFailed", &json!({
+ "error_message": "",
+ }));
+ Ok(())
+ })();
+
+ if let Err(error) = result {
+ set_error_result(&error);
+ }
+ 0
+}
+
diff --git a/os-apps/temper-agent/wasm/cron_trigger/Cargo.lock b/os-apps/temper-agent/wasm/cron_trigger/Cargo.lock
new file mode 100644
index 00000000..e9329605
--- /dev/null
+++ b/os-apps/temper-agent/wasm/cron_trigger/Cargo.lock
@@ -0,0 +1,121 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "cron-trigger"
+version = "0.1.0"
+dependencies = [
+ "temper-wasm-sdk",
+ "wasm-helpers",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.149"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "temper-wasm-sdk"
+version = "0.1.0"
+dependencies = [
+ "serde_json",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "wasm-helpers"
+version = "0.1.0"
+dependencies = [
+ "serde_json",
+ "temper-wasm-sdk",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/os-apps/temper-agent/wasm/cron_trigger/Cargo.toml b/os-apps/temper-agent/wasm/cron_trigger/Cargo.toml
new file mode 100644
index 00000000..e1565469
--- /dev/null
+++ b/os-apps/temper-agent/wasm/cron_trigger/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "cron-trigger"
+version = "0.1.0"
+edition = "2024"
+
+[lib]
+crate-type = ["cdylib"]
+
+[workspace]
+
+[dependencies]
+temper-wasm-sdk = { path = "../../../../crates/temper-wasm-sdk" }
+wasm-helpers = { path = "../wasm-helpers" }
diff --git a/os-apps/temper-agent/wasm/cron_trigger/src/lib.rs b/os-apps/temper-agent/wasm/cron_trigger/src/lib.rs
new file mode 100644
index 00000000..af9a9087
--- /dev/null
+++ b/os-apps/temper-agent/wasm/cron_trigger/src/lib.rs
@@ -0,0 +1,105 @@
+//! Cron Trigger — WASM module for firing scheduled agent runs.
+//!
+//! Creates a new TemperAgent entity with the cron job's configuration,
+//! including template variable substitution.
+
+use temper_wasm_sdk::prelude::*;
+use wasm_helpers::resolve_temper_api_url;
+
+#[unsafe(no_mangle)]
+pub extern "C" fn run(_ctx_ptr: i32, _ctx_len: i32) -> i32 {
+ let result = (|| -> Result<(), String> {
+ let ctx = Context::from_host()?;
+ ctx.log("info", "cron_trigger: starting");
+
+ let fields = ctx.entity_state.get("fields").cloned().unwrap_or(json!({}));
+ let temper_api_url = resolve_temper_api_url(&ctx, &fields);
+ let tenant = &ctx.tenant;
+
+ // Read cron job configuration
+ let soul_id = fields.get("soul_id").and_then(|v| v.as_str()).unwrap_or("");
+ let system_prompt = fields.get("system_prompt").and_then(|v| v.as_str()).unwrap_or("");
+ let user_message_template = fields.get("user_message_template").and_then(|v| v.as_str()).unwrap_or("");
+ let model = fields.get("model").and_then(|v| v.as_str()).unwrap_or("claude-sonnet-4-20250514");
+ let provider = fields.get("provider").and_then(|v| v.as_str()).unwrap_or("anthropic");
+ let tools_enabled = fields.get("tools_enabled").and_then(|v| v.as_str()).unwrap_or("read,write,edit,bash");
+ let sandbox_url = fields.get("sandbox_url").and_then(|v| v.as_str()).unwrap_or("");
+ let max_turns = fields.get("max_turns").and_then(|v| v.as_str()).unwrap_or("20");
+ let run_count = fields.get("run_count").and_then(|v| v.as_i64()).unwrap_or(0);
+ let last_result = fields.get("last_result").and_then(|v| v.as_str()).unwrap_or("");
+
+ // Template substitution
+ let user_message = user_message_template
+ .replace("{{run_count}}", &run_count.to_string())
+ .replace("{{last_result}}", last_result)
+ .replace("{{now}}", ""); // timestamp injected by cron_scheduler before trigger
+
+ ctx.log("info", &format!("cron_trigger: creating agent for run #{}", run_count));
+
+ let headers = vec![
+ ("content-type".to_string(), "application/json".to_string()),
+ ("x-tenant-id".to_string(), tenant.to_string()),
+ ("x-temper-principal-kind".to_string(), "admin".to_string()),
+ ];
+
+ // 1. Create TemperAgent entity
+ let create_url = format!("{temper_api_url}/tdata/TemperAgents");
+ let create_resp = ctx.http_call("POST", &create_url, &headers, "{}")?;
+ if create_resp.status < 200 || create_resp.status >= 300 {
+ return Err(format!("Failed to create agent (HTTP {}): {}", create_resp.status, &create_resp.body[..create_resp.body.len().min(200)]));
+ }
+
+ let agent: Value = serde_json::from_str(&create_resp.body)
+ .map_err(|e| format!("Failed to parse agent response: {e}"))?;
+ let agent_id = agent
+ .get("entity_id")
+ .or_else(|| agent.get("Id"))
+ .and_then(|v| v.as_str())
+ .unwrap_or("");
+ if agent_id.is_empty() {
+ return Err("Failed to extract created agent ID".to_string());
+ }
+
+ // 2. Configure the agent
+ let configure_url = format!(
+ "{temper_api_url}/tdata/TemperAgents('{agent_id}')/Temper.Agent.TemperAgent.Configure"
+ );
+ let configure_body = json!({
+ "system_prompt": system_prompt,
+ "user_message": user_message,
+ "model": model,
+ "provider": provider,
+ "tools_enabled": tools_enabled,
+ "sandbox_url": sandbox_url,
+ "max_turns": max_turns,
+ "soul_id": soul_id,
+ });
+ let configure_resp = ctx.http_call("POST", &configure_url, &headers, &configure_body.to_string())?;
+ if configure_resp.status < 200 || configure_resp.status >= 300 {
+ return Err(format!("Failed to configure agent (HTTP {})", configure_resp.status));
+ }
+
+ // 3. Provision the agent
+ let provision_url = format!(
+ "{temper_api_url}/tdata/TemperAgents('{agent_id}')/Temper.Agent.TemperAgent.Provision"
+ );
+ let provision_resp = ctx.http_call("POST", &provision_url, &headers, "{}")?;
+ if provision_resp.status < 200 || provision_resp.status >= 300 {
+ return Err(format!("Failed to provision agent (HTTP {})", provision_resp.status));
+ }
+
+ ctx.log("info", &format!("cron_trigger: agent {} created and provisioned", agent_id));
+
+ set_success_result("TriggerComplete", &json!({
+ "last_agent_id": agent_id,
+ "last_result": "",
+ }));
+
+ Ok(())
+ })();
+
+ if let Err(e) = result {
+ set_error_result(&e);
+ }
+ 0
+}
diff --git a/os-apps/temper-agent/wasm/heartbeat_scan/Cargo.lock b/os-apps/temper-agent/wasm/heartbeat_scan/Cargo.lock
new file mode 100644
index 00000000..aa2d0bdc
--- /dev/null
+++ b/os-apps/temper-agent/wasm/heartbeat_scan/Cargo.lock
@@ -0,0 +1,121 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "heartbeat-scan"
+version = "0.1.0"
+dependencies = [
+ "temper-wasm-sdk",
+ "wasm-helpers",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.149"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "temper-wasm-sdk"
+version = "0.1.0"
+dependencies = [
+ "serde_json",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "wasm-helpers"
+version = "0.1.0"
+dependencies = [
+ "serde_json",
+ "temper-wasm-sdk",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/os-apps/temper-agent/wasm/heartbeat_scan/Cargo.toml b/os-apps/temper-agent/wasm/heartbeat_scan/Cargo.toml
new file mode 100644
index 00000000..e91cc271
--- /dev/null
+++ b/os-apps/temper-agent/wasm/heartbeat_scan/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "heartbeat-scan"
+version = "0.1.0"
+edition = "2024"
+
+[lib]
+crate-type = ["cdylib"]
+
+[workspace]
+
+[dependencies]
+temper-wasm-sdk = { path = "../../../../crates/temper-wasm-sdk" }
+wasm-helpers = { path = "../wasm-helpers" }
diff --git a/os-apps/temper-agent/wasm/heartbeat_scan/src/lib.rs b/os-apps/temper-agent/wasm/heartbeat_scan/src/lib.rs
new file mode 100644
index 00000000..da77da83
--- /dev/null
+++ b/os-apps/temper-agent/wasm/heartbeat_scan/src/lib.rs
@@ -0,0 +1,150 @@
+//! Heartbeat Scanner — WASM module for detecting stale agents.
+//!
+//! Queries TemperAgent entities in non-terminal states, checks heartbeat freshness,
+//! and fires TimeoutFail on stale ones.
+
+use temper_wasm_sdk::prelude::*;
+use wasm_helpers::{entity_field_str, parse_iso8601_to_epoch_secs, resolve_temper_api_url};
+
+#[unsafe(no_mangle)]
+pub extern "C" fn run(_ctx_ptr: i32, _ctx_len: i32) -> i32 {
+ let result = (|| -> Result<(), String> {
+ let ctx = Context::from_host()?;
+ ctx.log("info", "heartbeat_scan: starting");
+
+ let fields = ctx.entity_state.get("fields").cloned().unwrap_or(json!({}));
+ let temper_api_url = resolve_temper_api_url(&ctx, &fields);
+ let tenant = &ctx.tenant;
+
+ // Get scanner's reference timestamp for "now"
+ let scan_started_at = fields
+ .get("last_scan_at")
+ .and_then(|v| v.as_str())
+ .unwrap_or("");
+ let now_secs = parse_iso8601_to_epoch_secs(scan_started_at).unwrap_or(0);
+
+ let headers = vec![
+ ("x-tenant-id".to_string(), tenant.to_string()),
+ ("x-temper-principal-kind".to_string(), "admin".to_string()),
+ ("accept".to_string(), "application/json".to_string()),
+ ];
+
+ // Query agents in non-terminal states
+ let filter = "$filter=Status ne 'Completed' and Status ne 'Failed' and Status ne 'Cancelled' and Status ne 'Created'";
+ let url = format!("{temper_api_url}/tdata/TemperAgents?{filter}");
+ let resp = ctx.http_call("GET", &url, &headers, "")?;
+
+ let mut stale_count: i64 = 0;
+
+ if resp.status == 200 {
+ let parsed: Value = serde_json::from_str(&resp.body).unwrap_or(json!({"value": []}));
+ let agents = parsed.get("value").and_then(|v| v.as_array()).cloned().unwrap_or_default();
+
+ ctx.log("info", &format!("heartbeat_scan: checking {} active agents", agents.len()));
+
+ for agent in &agents {
+ let agent_id = agent
+ .get("entity_id")
+ .and_then(|v| v.as_str())
+ .or_else(|| entity_field_str(agent, &["Id"]))
+ .unwrap_or("");
+ let last_heartbeat =
+ entity_field_str(agent, &["LastHeartbeatAt"]).unwrap_or("");
+ let timeout_secs: u64 = entity_field_str(agent, &["HeartbeatTimeoutSeconds"])
+ .and_then(|s| s.parse().ok())
+ .unwrap_or(300);
+
+ // Skip agents without heartbeat monitoring configured.
+ if timeout_secs == 0 {
+ continue;
+ }
+
+ let is_stale = if last_heartbeat.is_empty() {
+ // No heartbeat ever observed — stale
+ true
+ } else if now_secs > 0 {
+ // Compare heartbeat timestamp against current time
+ match parse_iso8601_to_epoch_secs(last_heartbeat) {
+ Some(hb_secs) => now_secs.saturating_sub(hb_secs) > timeout_secs,
+ None => {
+ ctx.log("warn", &format!(
+ "heartbeat_scan: agent {} has unparseable heartbeat timestamp '{}'",
+ agent_id, last_heartbeat
+ ));
+ false
+ }
+ }
+ } else {
+ // No reference time available; only flag agents with no heartbeat at all
+ ctx.log("info", &format!(
+ "heartbeat_scan: agent {} has heartbeat '{}' but no scan reference time, skipping comparison",
+ agent_id, last_heartbeat
+ ));
+ false
+ };
+
+ if is_stale {
+ let fail_url = format!(
+ "{temper_api_url}/tdata/TemperAgents('{agent_id}')/Temper.Agent.TemperAgent.TimeoutFail"
+ );
+ let elapsed_msg = if last_heartbeat.is_empty() {
+ "no heartbeat observed".to_string()
+ } else {
+ let hb_secs = parse_iso8601_to_epoch_secs(last_heartbeat).unwrap_or(0);
+ format!("last heartbeat {}s ago", now_secs.saturating_sub(hb_secs))
+ };
+ let fail_body = json!({
+ "error_message": format!(
+ "heartbeat timeout: {} (timeout: {}s)",
+ elapsed_msg, timeout_secs
+ )
+ });
+ match ctx.http_call("POST", &fail_url, &headers, &fail_body.to_string()) {
+ Ok(resp) if resp.status >= 200 && resp.status < 300 => {
+ stale_count += 1;
+ ctx.log(
+ "warn",
+ &format!("heartbeat_scan: failed stale agent {}", agent_id),
+ );
+ }
+ Ok(resp) => ctx.log(
+ "warn",
+ &format!(
+ "heartbeat_scan: TimeoutFail failed for {} (HTTP {})",
+ agent_id, resp.status
+ ),
+ ),
+ Err(error) => ctx.log(
+ "warn",
+ &format!(
+ "heartbeat_scan: TimeoutFail failed for {}: {}",
+ agent_id, error
+ ),
+ ),
+ }
+ } else {
+ ctx.log(
+ "info",
+ &format!(
+ "heartbeat_scan: agent {} heartbeat marker='{}' timeout={}s — alive",
+ agent_id, last_heartbeat, timeout_secs
+ ),
+ );
+ }
+ }
+ }
+
+ // Return scan complete
+ set_success_result("ScanComplete", &json!({
+ "last_scan_at": "scan-complete",
+ "stale_agents_found": stale_count,
+ }));
+
+ Ok(())
+ })();
+
+ if let Err(e) = result {
+ set_error_result(&e);
+ }
+ 0
+}
diff --git a/os-apps/temper-agent/wasm/heartbeat_scheduler/Cargo.lock b/os-apps/temper-agent/wasm/heartbeat_scheduler/Cargo.lock
new file mode 100644
index 00000000..9b68d615
--- /dev/null
+++ b/os-apps/temper-agent/wasm/heartbeat_scheduler/Cargo.lock
@@ -0,0 +1,121 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "heartbeat-scheduler"
+version = "0.1.0"
+dependencies = [
+ "temper-wasm-sdk",
+ "wasm-helpers",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.149"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "temper-wasm-sdk"
+version = "0.1.0"
+dependencies = [
+ "serde_json",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "wasm-helpers"
+version = "0.1.0"
+dependencies = [
+ "serde_json",
+ "temper-wasm-sdk",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/os-apps/temper-agent/wasm/heartbeat_scheduler/Cargo.toml b/os-apps/temper-agent/wasm/heartbeat_scheduler/Cargo.toml
new file mode 100644
index 00000000..469534ff
--- /dev/null
+++ b/os-apps/temper-agent/wasm/heartbeat_scheduler/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "heartbeat-scheduler"
+version = "0.1.0"
+edition = "2024"
+
+[lib]
+crate-type = ["cdylib"]
+
+[workspace]
+
+[dependencies]
+temper-wasm-sdk = { path = "../../../../crates/temper-wasm-sdk" }
+wasm-helpers = { path = "../wasm-helpers" }
diff --git a/os-apps/temper-agent/wasm/heartbeat_scheduler/src/lib.rs b/os-apps/temper-agent/wasm/heartbeat_scheduler/src/lib.rs
new file mode 100644
index 00000000..2725678e
--- /dev/null
+++ b/os-apps/temper-agent/wasm/heartbeat_scheduler/src/lib.rs
@@ -0,0 +1,48 @@
+use temper_wasm_sdk::prelude::*;
+use wasm_helpers::resolve_temper_api_url;
+
+#[unsafe(no_mangle)]
+pub extern "C" fn run(_ctx_ptr: i32, _ctx_len: i32) -> i32 {
+ let result = (|| -> Result<(), String> {
+ let ctx = Context::from_host()?;
+ let fields = ctx.entity_state.get("fields").cloned().unwrap_or_else(|| json!({}));
+ let interval_seconds = fields
+ .get("scan_interval_seconds")
+ .and_then(|v| v.as_str())
+ .and_then(|v| v.parse::().ok())
+ .unwrap_or(30)
+ .clamp(1, 300);
+ let base_url = resolve_temper_api_url(&ctx, &fields);
+ let headers = vec![
+ ("x-tenant-id".to_string(), ctx.tenant.clone()),
+ ("x-temper-principal-kind".to_string(), "admin".to_string()),
+ ("accept".to_string(), "application/json".to_string()),
+ ("content-type".to_string(), "application/json".to_string()),
+ ];
+
+ let wait_url = format!(
+ "{base_url}/observe/entities/{}/{}/wait?statuses=__never__&timeout_ms={}&poll_ms=250",
+ ctx.entity_type,
+ ctx.entity_id,
+ interval_seconds * 1000
+ );
+ let _ = ctx.http_call("GET", &wait_url, &headers, "")?;
+
+ let action_url = format!(
+ "{base_url}/tdata/HeartbeatMonitors('{}')/Temper.Agent.HeartbeatMonitor.ScheduledScan",
+ ctx.entity_id
+ );
+ let _ = ctx.http_call("POST", &action_url, &headers, "{}")?;
+
+ set_success_result("ScheduleFailed", &json!({
+ "error_message": "",
+ }));
+ Ok(())
+ })();
+
+ if let Err(error) = result {
+ set_error_result(&error);
+ }
+ 0
+}
+
diff --git a/os-apps/temper-agent/wasm/llm_caller/Cargo.lock b/os-apps/temper-agent/wasm/llm_caller/Cargo.lock
index 14e1d9bc..3a5c0de8 100644
--- a/os-apps/temper-agent/wasm/llm_caller/Cargo.lock
+++ b/os-apps/temper-agent/wasm/llm_caller/Cargo.lock
@@ -12,6 +12,7 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
name = "llm-caller"
version = "0.1.0"
dependencies = [
+ "session-tree-lib",
"temper-wasm-sdk",
]
@@ -81,6 +82,13 @@ dependencies = [
"zmij",
]
+[[package]]
+name = "session-tree-lib"
+version = "0.1.0"
+dependencies = [
+ "serde_json",
+]
+
[[package]]
name = "syn"
version = "2.0.117"
diff --git a/os-apps/temper-agent/wasm/llm_caller/Cargo.toml b/os-apps/temper-agent/wasm/llm_caller/Cargo.toml
index eb0e8cff..dbdf5b9f 100644
--- a/os-apps/temper-agent/wasm/llm_caller/Cargo.toml
+++ b/os-apps/temper-agent/wasm/llm_caller/Cargo.toml
@@ -10,3 +10,4 @@ crate-type = ["cdylib"]
[dependencies]
temper-wasm-sdk = { path = "../../../../crates/temper-wasm-sdk" }
+session-tree-lib = { path = "../session-tree-lib" }
diff --git a/os-apps/temper-agent/wasm/llm_caller/src/lib.rs b/os-apps/temper-agent/wasm/llm_caller/src/lib.rs
index 6b598af9..c7e1c3f3 100644
--- a/os-apps/temper-agent/wasm/llm_caller/src/lib.rs
+++ b/os-apps/temper-agent/wasm/llm_caller/src/lib.rs
@@ -16,6 +16,7 @@
//! Build: `cargo build --target wasm32-unknown-unknown --release`
use temper_wasm_sdk::prelude::*;
+use session_tree_lib::SessionTree;
/// Entry point — NOT using `temper_module!` because we need dynamic callback actions.
#[unsafe(no_mangle)]
@@ -132,6 +133,32 @@ anthropic_api_key (or api_key) for anthropic, openrouter_api_key (or api_key) fo
let temper_api_url = temper_api_url(&ctx);
let tenant = &ctx.tenant;
+ // Session tree fields (Pi architecture)
+ let session_file_id = fields
+ .get("session_file_id")
+ .and_then(|v| v.as_str())
+ .unwrap_or("");
+ let session_leaf_id = fields
+ .get("session_leaf_id")
+ .and_then(|v| v.as_str())
+ .unwrap_or("");
+
+ // Soul and steering fields
+ let soul_id = fields
+ .get("soul_id")
+ .and_then(|v| v.as_str())
+ .unwrap_or("");
+ let max_follow_ups: i64 = fields
+ .get("max_follow_ups")
+ .and_then(|v| v.as_str())
+ .and_then(|s| s.parse().ok())
+ .unwrap_or(5);
+ let reserve_tokens: usize = fields
+ .get("reserve_tokens")
+ .and_then(|v| v.as_str())
+ .and_then(|s| s.parse().ok())
+ .unwrap_or(20000);
+
// Read conversation — from TemperFS if file_id set, else inline state.
// First turn uses `user_message` (the actual user task from Provision).
// `system_prompt` is always sent as the Anthropic system parameter, never as a message.
@@ -139,40 +166,107 @@ anthropic_api_key (or api_key) for anthropic, openrouter_api_key (or api_key) fo
return Err("user_message is empty — nothing to send to the LLM".to_string());
}
let first_turn_content = user_message;
- let mut messages: Vec = if !conversation_file_id.is_empty() {
- read_conversation_from_temperfs(
- &ctx,
- &temper_api_url,
- tenant,
- conversation_file_id,
- first_turn_content,
- )?
+
+ // Determine which session storage to use
+ let use_session_tree = !session_file_id.is_empty() && !session_leaf_id.is_empty();
+
+ let (mut messages, mut session_tree) = if use_session_tree {
+ let session_jsonl = read_session_from_temperfs(&ctx, &temper_api_url, tenant, session_file_id)?;
+ if session_jsonl.is_empty() {
+ // First turn — tree was just created by sandbox_provisioner but empty
+ let tree = SessionTree::from_jsonl(&session_jsonl);
+ let msgs = vec![json!({ "role": "user", "content": first_turn_content })];
+ (msgs, Some(tree))
+ } else {
+ let tree = SessionTree::from_jsonl(&session_jsonl);
+ let msgs = tree.build_context(session_leaf_id);
+ if msgs.is_empty() {
+ (vec![json!({ "role": "user", "content": first_turn_content })], Some(tree))
+ } else {
+ (msgs, Some(tree))
+ }
+ }
+ } else if !conversation_file_id.is_empty() {
+ // Legacy flat JSON mode
+ let msgs = read_conversation_from_temperfs(
+ &ctx, &temper_api_url, tenant, conversation_file_id, first_turn_content,
+ )?;
+ (msgs, None)
} else {
- let conversation_json = fields
- .get("conversation")
- .and_then(|v| v.as_str())
- .unwrap_or("");
+ // Inline state
+ let conversation_json = fields.get("conversation").and_then(|v| v.as_str()).unwrap_or("");
if conversation_json.is_empty() {
- vec![json!({ "role": "user", "content": first_turn_content })]
+ (vec![json!({ "role": "user", "content": first_turn_content })], None)
} else {
- serde_json::from_str(conversation_json).unwrap_or_else(|_| {
+ (serde_json::from_str(conversation_json).unwrap_or_else(|_| {
vec![json!({ "role": "user", "content": first_turn_content })]
- })
+ }), None)
}
};
// Build tool definitions based on tools_enabled
let tools = build_tool_definitions(tools_enabled, sandbox_url, workdir);
+ // Check compaction threshold (Pi architecture)
+ if use_session_tree {
+ if let Some(ref tree) = session_tree {
+ let context_tokens = tree.estimate_tokens(session_leaf_id);
+ // Model context windows (approximate)
+ let context_window: usize = if model.contains("opus") { 200000 }
+ else if model.contains("haiku") { 200000 }
+ else { 200000 }; // sonnet default
+ if context_tokens > context_window.saturating_sub(reserve_tokens) {
+ ctx.log("info", &format!(
+ "llm_caller: context_tokens ({}) exceeds threshold ({}), triggering compaction",
+ context_tokens, context_window.saturating_sub(reserve_tokens)
+ ));
+ set_success_result("NeedsCompaction", &json!({
+ "context_tokens": context_tokens,
+ "session_leaf_id": session_leaf_id,
+ }));
+ return Ok(());
+ }
+ }
+ }
+
+ // System prompt assembly (Pi architecture):
+ // 1. Soul content (from AgentSoul entity via TemperFS)
+ // 2. system_prompt override (from Configure action)
+ // 3. Available skills XML block
+ // 4. Memory context
+ let assembled_system_prompt = assemble_system_prompt(
+ &ctx, &temper_api_url, tenant, soul_id, system_prompt,
+ )?;
+
+ emit_progress_ignore(
+ &ctx,
+ json!({
+ "kind": "prompt_assembled",
+ "message": "system prompt assembled",
+ "system_prompt": assembled_system_prompt,
+ }),
+ );
+ let mock_hang = provider == "mock" && mock_plan_requests_hang(&messages);
+ if !mock_hang {
+ let _ = send_heartbeat(&ctx, &temper_api_url, tenant);
+ }
+ emit_progress_ignore(
+ &ctx,
+ json!({
+ "kind": "llm_request_started",
+ "message": format!("calling provider={provider} model={model}"),
+ }),
+ );
+
// Call LLM API
let response = match provider.as_str() {
- "mock" => call_mock(&ctx, &messages)?,
+ "mock" => call_mock(&ctx, &messages, &assembled_system_prompt, &tools)?,
"anthropic" => call_anthropic(
&ctx,
&api_key,
&anthropic_api_url,
model,
- system_prompt,
+ &assembled_system_prompt,
&messages,
&tools,
&anthropic_auth_mode,
@@ -182,7 +276,7 @@ anthropic_api_key (or api_key) for anthropic, openrouter_api_key (or api_key) fo
&api_key,
&openrouter_api_url,
model,
- system_prompt,
+ &assembled_system_prompt,
&messages,
&tools,
&openrouter_site_url,
@@ -198,6 +292,14 @@ anthropic_api_key (or api_key) for anthropic, openrouter_api_key (or api_key) fo
response.stop_reason
),
);
+ emit_progress_ignore(
+ &ctx,
+ json!({
+ "kind": "llm_response",
+ "message": format!("provider returned stop_reason={}", response.stop_reason),
+ "stop_reason": response.stop_reason.clone(),
+ }),
+ );
// Append assistant response to conversation
messages.push(json!({
@@ -238,19 +340,36 @@ anthropic_api_key (or api_key) for anthropic, openrouter_api_key (or api_key) fo
.cloned()
.collect();
+ // Update session tree if in tree mode
+ let new_leaf = if use_session_tree {
+ if let Some(ref mut tree) = session_tree {
+ let parent = session_leaf_id;
+ let (leaf, _) = tree.append_assistant_message(
+ parent,
+ &response.content,
+ response.output_tokens as usize,
+ );
+ let updated_jsonl = tree.to_jsonl();
+ write_session_to_temperfs(&ctx, &temper_api_url, tenant, session_file_id, &updated_jsonl)?;
+ Some(leaf)
+ } else { None }
+ } else { None };
+
let tool_calls_json = serde_json::to_string(&tool_calls).unwrap_or_default();
let mut params = json!({
"pending_tool_calls": tool_calls_json,
"input_tokens": response.input_tokens,
"output_tokens": response.output_tokens,
});
+ if let Some(leaf) = new_leaf {
+ params["session_leaf_id"] = json!(leaf);
+ }
if let Some(ref conv) = conv_param {
params["conversation"] = json!(conv);
}
set_success_result("ProcessToolCalls", ¶ms);
}
"end_turn" | "stop" => {
- // Extract text result
let result_text = response
.content
.as_array()
@@ -266,15 +385,48 @@ anthropic_api_key (or api_key) for anthropic, openrouter_api_key (or api_key) fo
.collect::>()
.join("\n");
- let mut params = json!({
- "result": result_text,
- "input_tokens": response.input_tokens,
- "output_tokens": response.output_tokens,
- });
- if let Some(ref conv) = conv_param {
- params["conversation"] = json!(conv);
+ // Update session tree if in tree mode
+ if use_session_tree {
+ if let Some(ref mut tree) = session_tree {
+ let parent = session_leaf_id;
+ let (new_leaf, _) = tree.append_assistant_message(
+ parent,
+ &response.content,
+ response.output_tokens as usize,
+ );
+ let updated_jsonl = tree.to_jsonl();
+ write_session_to_temperfs(&ctx, &temper_api_url, tenant, session_file_id, &updated_jsonl)?;
+
+ // Route through steering check if follow-ups are enabled
+ if max_follow_ups > 0 {
+ set_success_result("CheckSteering", &json!({
+ "result": result_text,
+ "session_leaf_id": new_leaf,
+ "input_tokens": response.input_tokens,
+ "output_tokens": response.output_tokens,
+ }));
+ } else {
+ let params = json!({
+ "result": result_text,
+ "session_leaf_id": new_leaf,
+ "input_tokens": response.input_tokens,
+ "output_tokens": response.output_tokens,
+ });
+ set_success_result("RecordResult", ¶ms);
+ }
+ }
+ } else {
+ // Legacy mode — direct to RecordResult
+ let mut params = json!({
+ "result": result_text,
+ "input_tokens": response.input_tokens,
+ "output_tokens": response.output_tokens,
+ });
+ if let Some(ref conv) = conv_param {
+ params["conversation"] = json!(conv);
+ }
+ set_success_result("RecordResult", ¶ms);
}
- set_success_result("RecordResult", ¶ms);
}
other => {
set_success_result(
@@ -345,31 +497,39 @@ fn resolve_provider_api_key(ctx: &Context, provider: &str) -> Result Result {
+fn call_mock(
+ ctx: &Context,
+ messages: &[Value],
+ assembled_system_prompt: &str,
+ _tools: &[Value],
+) -> Result {
ctx.log("info", "llm_caller: using deterministic mock provider");
- let signal_summary = extract_mock_signal_summary(messages)?;
- let analysis = build_mock_analysis(&signal_summary);
- let analysis_text = serde_json::to_string_pretty(&analysis)
- .map_err(|e| format!("failed to serialize mock analysis: {e}"))?;
+ if mock_plan_requests_hang(messages) {
+ simulate_mock_hang(ctx)?;
+ return Err("mock hang scenario finished without heartbeat".to_string());
+ }
- Ok(LlmResponse {
- content: json!([{
- "type": "text",
- "text": analysis_text,
- }]),
- stop_reason: "end_turn".to_string(),
- input_tokens: messages
- .iter()
- .map(|message| {
- message
- .get("content")
- .map(stringify_content)
- .unwrap_or_default()
- .len() as i64
- })
- .sum::(),
- output_tokens: analysis_text.len() as i64,
- })
+ let assistant_turns = messages
+ .iter()
+ .filter(|message| message.get("role").and_then(Value::as_str) == Some("assistant"))
+ .count();
+
+ if let Some(step) = extract_mock_plan(messages)
+ .and_then(|steps| steps.get(assistant_turns).cloned())
+ {
+ return build_mock_step_response(messages, assembled_system_prompt, assistant_turns, &step);
+ }
+
+ let latest_user = latest_user_text(messages);
+ let text = resolve_mock_template(
+ latest_user
+ .as_deref()
+ .filter(|value| !value.trim().is_empty())
+ .unwrap_or("mock provider completed"),
+ assembled_system_prompt,
+ latest_user.as_deref().unwrap_or(""),
+ );
+ Ok(mock_text_response(messages, text))
}
fn extract_mock_signal_summary(messages: &[Value]) -> Result {
@@ -1153,6 +1313,217 @@ fn stringify_content(value: &Value) -> String {
}
}
+fn emit_progress_ignore(ctx: &Context, payload: Value) {
+ let _ = ctx.emit_progress(&payload);
+}
+
+fn send_heartbeat(ctx: &Context, temper_api_url: &str, tenant: &str) -> Result<(), String> {
+ let url = format!(
+ "{temper_api_url}/tdata/TemperAgents('{}')/Temper.Agent.TemperAgent.Heartbeat",
+ ctx.entity_id
+ );
+ let body = json!({ "last_heartbeat_at": "alive" });
+ let headers = vec![
+ ("content-type".to_string(), "application/json".to_string()),
+ ("x-tenant-id".to_string(), tenant.to_string()),
+ ("x-temper-principal-kind".to_string(), "admin".to_string()),
+ ];
+ let _ = ctx.http_call("POST", &url, &headers, &body.to_string())?;
+ Ok(())
+}
+
+fn mock_plan_requests_hang(messages: &[Value]) -> bool {
+ if let Some(steps) = extract_mock_plan(messages)
+ && steps
+ .iter()
+ .any(|step| step.get("mode").and_then(Value::as_str) == Some("hang"))
+ {
+ return true;
+ }
+ latest_user_text(messages)
+ .map(|text| text.contains("[mock-hang]"))
+ .unwrap_or(false)
+}
+
+fn simulate_mock_hang(ctx: &Context) -> Result<(), String> {
+ let base_url = temper_api_url(ctx);
+ let url = format!(
+ "{base_url}/observe/entities/{}/{}/wait?statuses=__never__&timeout_ms=10000&poll_ms=250",
+ ctx.entity_type, ctx.entity_id
+ );
+ let headers = vec![
+ ("x-tenant-id".to_string(), ctx.tenant.clone()),
+ ("x-temper-principal-kind".to_string(), "admin".to_string()),
+ ("accept".to_string(), "application/json".to_string()),
+ ];
+ let _ = ctx.http_call("GET", &url, &headers, "")?;
+ Ok(())
+}
+
+fn extract_mock_plan(messages: &[Value]) -> Option> {
+ for message in messages {
+ if message.get("role").and_then(Value::as_str) != Some("user") {
+ continue;
+ }
+ let raw = stringify_content(message.get("content").unwrap_or(&Value::Null));
+ let Ok(parsed) = serde_json::from_str::(&raw) else {
+ continue;
+ };
+ if let Some(steps) = parsed.get("steps").and_then(Value::as_array) {
+ return Some(steps.clone());
+ }
+ if let Some(steps) = parsed
+ .get("mock_plan")
+ .and_then(|value| value.get("steps"))
+ .and_then(Value::as_array)
+ {
+ return Some(steps.clone());
+ }
+ }
+ None
+}
+
+fn build_mock_step_response(
+ messages: &[Value],
+ assembled_system_prompt: &str,
+ assistant_turns: usize,
+ step: &Value,
+) -> Result {
+ if step.get("mode").and_then(Value::as_str) == Some("hang") {
+ return Ok(mock_text_response(messages, "mock hang placeholder".to_string()));
+ }
+
+ let mut content = Vec::::new();
+ if let Some(text) = step.get("text").and_then(Value::as_str) {
+ let resolved = resolve_mock_template(
+ text,
+ assembled_system_prompt,
+ latest_user_text(messages).as_deref().unwrap_or(""),
+ );
+ if !resolved.is_empty() {
+ content.push(json!({ "type": "text", "text": resolved }));
+ }
+ }
+
+ if let Some(tool_calls) = step.get("tool_calls").and_then(Value::as_array) {
+ for (index, tool_call) in tool_calls.iter().enumerate() {
+ let name = tool_call
+ .get("name")
+ .and_then(Value::as_str)
+ .unwrap_or("unknown_tool");
+ let input = tool_call.get("input").cloned().unwrap_or_else(|| json!({}));
+ let id = tool_call
+ .get("id")
+ .and_then(Value::as_str)
+ .map(str::to_string)
+ .unwrap_or_else(|| format!("mock-tool-{assistant_turns}-{index}"));
+ content.push(json!({
+ "type": "tool_use",
+ "id": id,
+ "name": name,
+ "input": input,
+ }));
+ }
+ }
+
+ if content
+ .iter()
+ .any(|block| block.get("type").and_then(Value::as_str) == Some("tool_use"))
+ {
+ let output_len = serde_json::to_string(&content).unwrap_or_default().len() as i64;
+ return Ok(LlmResponse {
+ content: Value::Array(content),
+ stop_reason: "tool_use".to_string(),
+ input_tokens: estimate_message_tokens(messages),
+ output_tokens: output_len,
+ });
+ }
+
+ let final_text = step
+ .get("final_text")
+ .or_else(|| step.get("text"))
+ .and_then(Value::as_str)
+ .unwrap_or("mock provider completed");
+ Ok(mock_text_response(
+ messages,
+ resolve_mock_template(
+ final_text,
+ assembled_system_prompt,
+ latest_user_text(messages).as_deref().unwrap_or(""),
+ ),
+ ))
+}
+
+fn mock_text_response(messages: &[Value], text: String) -> LlmResponse {
+ LlmResponse {
+ content: json!([{ "type": "text", "text": text.clone() }]),
+ stop_reason: "end_turn".to_string(),
+ input_tokens: estimate_message_tokens(messages),
+ output_tokens: text.len() as i64,
+ }
+}
+
+fn estimate_message_tokens(messages: &[Value]) -> i64 {
+ messages
+ .iter()
+ .map(|message| {
+ message
+ .get("content")
+ .map(stringify_content)
+ .unwrap_or_default()
+ .len() as i64
+ })
+ .sum::()
+}
+
+fn latest_user_text(messages: &[Value]) -> Option {
+ messages
+ .iter()
+ .rev()
+ .find(|message| message.get("role").and_then(Value::as_str) == Some("user"))
+ .map(|message| stringify_content(message.get("content").unwrap_or(&Value::Null)))
+}
+
+fn resolve_mock_template(template: &str, assembled_system_prompt: &str, latest_user: &str) -> String {
+ let mut text = template.to_string();
+ text = text.replace("{{latest_user}}", latest_user);
+ text = text.replace("{{memory_block}}", &extract_tag_block(assembled_system_prompt, "agent_memory"));
+ text = text.replace(
+ "{{memory_keys}}",
+ &extract_memory_keys(assembled_system_prompt).join(", "),
+ );
+ text = text.replace(
+ "{{memory_count}}",
+ &extract_memory_keys(assembled_system_prompt).len().to_string(),
+ );
+ text = text.replace("{{skills_block}}", &extract_tag_block(assembled_system_prompt, "available_skills"));
+ text
+}
+
+fn extract_tag_block(text: &str, tag: &str) -> String {
+ let start_tag = format!("<{tag}>");
+ let end_tag = format!("{tag}>");
+ let Some(start) = text.find(&start_tag) else {
+ return String::new();
+ };
+ let Some(end) = text[start..].find(&end_tag) else {
+ return String::new();
+ };
+ text[start..start + end + end_tag.len()].to_string()
+}
+
+fn extract_memory_keys(text: &str) -> Vec {
+ text.lines()
+ .filter_map(|line| {
+ let marker = "key=\"";
+ let start = line.find(marker)? + marker.len();
+ let rest = &line[start..];
+ let end = rest.find('"')?;
+ Some(rest[..end].to_string())
+ })
+ .collect()
+}
+
fn convert_messages_to_openrouter(messages: &[Value]) -> Vec {
let mut out = Vec::::new();
for msg in messages {
@@ -1349,6 +1720,120 @@ fn build_tool_definitions(tools_enabled: &str, sandbox_url: &str, workdir: &str)
}));
}
+ if enabled.contains(&"read_entity") {
+ tools.push(json!({
+ "name": "read_entity",
+ "description": "Read a TemperFS-backed entity content file by file_id.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "file_id": { "type": "string", "description": "TemperFS File entity ID" }
+ },
+ "required": ["file_id"]
+ }
+ }));
+ }
+
+ if enabled.contains(&"save_memory") {
+ tools.push(json!({
+ "name": "save_memory",
+ "description": "Persist a memory entry scoped to the agent soul.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "key": { "type": "string" },
+ "content": { "type": "string" },
+ "memory_type": { "type": "string" }
+ },
+ "required": ["key", "content"]
+ }
+ }));
+ }
+
+ if enabled.contains(&"recall_memory") {
+ tools.push(json!({
+ "name": "recall_memory",
+ "description": "Recall memories matching a key or content substring.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "query": { "type": "string" }
+ },
+ "required": ["query"]
+ }
+ }));
+ }
+
+ if enabled.contains(&"spawn_agent") {
+ tools.push(json!({
+ "name": "spawn_agent",
+ "description": "Create, configure, and provision a child TemperAgent.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "agent_id": { "type": "string" },
+ "task": { "type": "string" },
+ "model": { "type": "string" },
+ "provider": { "type": "string" },
+ "max_turns": { "type": "integer" },
+ "tools": { "type": "string" },
+ "soul_id": { "type": "string" },
+ "background": { "type": "boolean" }
+ },
+ "required": ["task"]
+ }
+ }));
+ tools.push(json!({
+ "name": "list_agents",
+ "description": "List child agents spawned by this agent.",
+ "input_schema": {
+ "type": "object",
+ "properties": {},
+ "required": []
+ }
+ }));
+ tools.push(json!({
+ "name": "abort_agent",
+ "description": "Cancel a child agent by ID.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "agent_id": { "type": "string" }
+ },
+ "required": ["agent_id"]
+ }
+ }));
+ tools.push(json!({
+ "name": "steer_agent",
+ "description": "Queue a steering message for a child agent.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "agent_id": { "type": "string" },
+ "message": { "type": "string" }
+ },
+ "required": ["agent_id", "message"]
+ }
+ }));
+ }
+
+ if enabled.contains(&"run_coding_agent") {
+ tools.push(json!({
+ "name": "run_coding_agent",
+ "description": "Run a coding agent CLI command inside the sandbox.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "agent_type": { "type": "string" },
+ "task": { "type": "string" },
+ "workdir": { "type": "string" },
+ "background": { "type": "boolean" }
+ },
+ "required": ["agent_type", "task"]
+ }
+ }));
+ }
+
if enabled.contains(&"logfire_query") {
tools.push(json!({
"name": "logfire_query",
@@ -1375,6 +1860,129 @@ fn build_tool_definitions(tools_enabled: &str, sandbox_url: &str, workdir: &str)
}));
}
+ if enabled.contains(&"save_memory") {
+ tools.push(json!({
+ "name": "save_memory",
+ "description": "Save a memory for future agent sessions. Memories persist across runs.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "key": { "type": "string", "description": "Unique key for this memory" },
+ "content": { "type": "string", "description": "Memory content (markdown)" },
+ "memory_type": { "type": "string", "enum": ["user", "feedback", "project", "reference"], "description": "Type of memory" }
+ },
+ "required": ["key", "content", "memory_type"]
+ }
+ }));
+ }
+
+ if enabled.contains(&"recall_memory") {
+ tools.push(json!({
+ "name": "recall_memory",
+ "description": "Search and recall memories from previous sessions.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "query": { "type": "string", "description": "Search query to find relevant memories" }
+ },
+ "required": ["query"]
+ }
+ }));
+ }
+
+ if enabled.contains(&"spawn_agent") {
+ tools.push(json!({
+ "name": "spawn_agent",
+ "description": "Spawn a child TemperAgent to handle a subtask. The child runs autonomously and returns its result.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "agent_id": { "type": "string", "description": "Optional deterministic child agent ID" },
+ "task": { "type": "string", "description": "The task for the child agent" },
+ "model": { "type": "string", "description": "LLM model to use (optional, defaults to parent's model)" },
+ "provider": { "type": "string", "description": "LLM provider to use (optional, defaults to parent's provider)" },
+ "max_turns": { "type": "integer", "description": "Maximum turns for the child (optional, default 20)" },
+ "tools": { "type": "string", "description": "Comma-separated tools to enable (optional, defaults to parent's tools)" },
+ "soul_id": { "type": "string", "description": "Soul ID to use (optional, defaults to parent's soul)" },
+ "background": { "type": "boolean", "description": "If true, return after provisioning without waiting for completion" }
+ },
+ "required": ["task"]
+ }
+ }));
+ }
+
+ if enabled.contains(&"list_agents") {
+ tools.push(json!({
+ "name": "list_agents",
+ "description": "List child agents spawned by this agent and their status.",
+ "input_schema": {
+ "type": "object",
+ "properties": {},
+ "required": []
+ }
+ }));
+ }
+
+ if enabled.contains(&"steer_agent") {
+ tools.push(json!({
+ "name": "steer_agent",
+ "description": "Send a follow-up message to a child agent mid-run.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "agent_id": { "type": "string", "description": "The child agent entity ID" },
+ "message": { "type": "string", "description": "The steering message to inject" }
+ },
+ "required": ["agent_id", "message"]
+ }
+ }));
+ }
+
+ if enabled.contains(&"abort_agent") {
+ tools.push(json!({
+ "name": "abort_agent",
+ "description": "Cancel a running child agent.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "agent_id": { "type": "string", "description": "The child agent entity ID to cancel" }
+ },
+ "required": ["agent_id"]
+ }
+ }));
+ }
+
+ if enabled.contains(&"read_entity") {
+ tools.push(json!({
+ "name": "read_entity",
+ "description": "Read a TemperFS file by ID. Use this to load skill content, soul documents, or any other entity-backed file.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "file_id": { "type": "string", "description": "The TemperFS File entity ID to read" }
+ },
+ "required": ["file_id"]
+ }
+ }));
+ }
+
+ if enabled.contains(&"run_coding_agent") {
+ tools.push(json!({
+ "name": "run_coding_agent",
+ "description": "Spawn a coding agent CLI process (Claude Code, Codex, Pi, OpenCode) in the sandbox.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "agent_type": { "type": "string", "enum": ["claude-code", "codex", "pi", "opencode"], "description": "Which coding agent CLI to use" },
+ "task": { "type": "string", "description": "The task for the coding agent" },
+ "workdir": { "type": "string", "description": "Working directory in the sandbox (optional)" },
+ "background": { "type": "boolean", "description": "Run in background (default: false)" }
+ },
+ "required": ["agent_type", "task"]
+ }
+ }));
+ }
+
tools
}
@@ -1389,7 +1997,7 @@ fn read_conversation_from_temperfs(
let url = format!("{temper_api_url}/tdata/Files('{file_id}')/$value");
let headers = vec![
("x-tenant-id".to_string(), tenant.to_string()),
- ("x-temper-principal-kind".to_string(), "system".to_string()),
+ ("x-temper-principal-kind".to_string(), "admin".to_string()),
("accept".to_string(), "application/json".to_string()),
];
@@ -1448,7 +2056,7 @@ fn write_conversation_to_temperfs(
let headers = vec![
("content-type".to_string(), "application/json".to_string()),
("x-tenant-id".to_string(), tenant.to_string()),
- ("x-temper-principal-kind".to_string(), "system".to_string()),
+ ("x-temper-principal-kind".to_string(), "admin".to_string()),
];
// Wrap messages array in the TemperFS conversation format
@@ -1472,3 +2080,205 @@ fn write_conversation_to_temperfs(
))
}
}
+
+/// Read session JSONL from TemperFS.
+fn read_session_from_temperfs(
+ ctx: &Context,
+ temper_api_url: &str,
+ tenant: &str,
+ file_id: &str,
+) -> Result {
+ let url = format!("{temper_api_url}/tdata/Files('{file_id}')/$value");
+ let headers = vec![
+ ("x-tenant-id".to_string(), tenant.to_string()),
+ ("x-temper-principal-kind".to_string(), "admin".to_string()),
+ ];
+ let resp = ctx.http_call("GET", &url, &headers, "")?;
+ if resp.status == 200 {
+ Ok(resp.body)
+ } else if resp.status == 404 {
+ Ok(String::new())
+ } else {
+ Err(format!("TemperFS session read failed (HTTP {})", resp.status))
+ }
+}
+
+/// Write session JSONL to TemperFS.
+fn write_session_to_temperfs(
+ ctx: &Context,
+ temper_api_url: &str,
+ tenant: &str,
+ file_id: &str,
+ jsonl: &str,
+) -> Result<(), String> {
+ let url = format!("{temper_api_url}/tdata/Files('{file_id}')/$value");
+ let headers = vec![
+ ("content-type".to_string(), "text/plain".to_string()),
+ ("x-tenant-id".to_string(), tenant.to_string()),
+ ("x-temper-principal-kind".to_string(), "admin".to_string()),
+ ];
+ let resp = ctx.http_call("PUT", &url, &headers, jsonl)?;
+ if resp.status >= 200 && resp.status < 300 {
+ Ok(())
+ } else {
+ Err(format!("TemperFS session write failed (HTTP {})", resp.status))
+ }
+}
+
+/// Assemble the full system prompt from soul + override + skills + memory.
+fn assemble_system_prompt(
+ ctx: &Context,
+ temper_api_url: &str,
+ tenant: &str,
+ soul_id: &str,
+ system_prompt_override: &str,
+) -> Result {
+ let mut parts: Vec = Vec::new();
+
+ // 1. Soul content
+ if !soul_id.is_empty() {
+ match load_soul_content(ctx, temper_api_url, tenant, soul_id) {
+ Ok(content) if !content.is_empty() => parts.push(content),
+ Ok(_) => ctx.log("warn", "assemble_system_prompt: soul content is empty"),
+ Err(e) => ctx.log("warn", &format!("assemble_system_prompt: failed to load soul: {e}")),
+ }
+ }
+
+ // 2. System prompt override
+ if !system_prompt_override.is_empty() {
+ parts.push(system_prompt_override.to_string());
+ }
+
+ // 3. Available skills
+ if !soul_id.is_empty() {
+ match load_skills_block(ctx, temper_api_url, tenant) {
+ Ok(block) if !block.is_empty() => parts.push(block),
+ Ok(_) => {}
+ Err(e) => ctx.log("warn", &format!("assemble_system_prompt: failed to load skills: {e}")),
+ }
+ }
+
+ // 4. Memory context
+ if !soul_id.is_empty() {
+ match load_memory_block(ctx, temper_api_url, tenant, soul_id) {
+ Ok(block) if !block.is_empty() => parts.push(block),
+ Ok(_) => {}
+ Err(e) => ctx.log("warn", &format!("assemble_system_prompt: failed to load memory: {e}")),
+ }
+ }
+
+ // Fall back to bare system_prompt if nothing loaded
+ if parts.is_empty() {
+ return Ok(system_prompt_override.to_string());
+ }
+
+ Ok(parts.join("\n\n"))
+}
+
+/// Load soul content from AgentSoul entity.
+fn load_soul_content(
+ ctx: &Context,
+ temper_api_url: &str,
+ tenant: &str,
+ soul_id: &str,
+) -> Result {
+ let url = format!("{temper_api_url}/tdata/AgentSouls('{soul_id}')");
+ let headers = vec![
+ ("x-tenant-id".to_string(), tenant.to_string()),
+ ("x-temper-principal-kind".to_string(), "admin".to_string()),
+ ("accept".to_string(), "application/json".to_string()),
+ ];
+ let resp = ctx.http_call("GET", &url, &headers, "")?;
+ if resp.status != 200 {
+ return Err(format!("soul read failed (HTTP {})", resp.status));
+ }
+ let parsed: Value = serde_json::from_str(&resp.body).unwrap_or(json!({}));
+ let content_file_id = entity_field_str(&parsed, &["ContentFileId"]).unwrap_or("");
+ if content_file_id.is_empty() {
+ return Ok(String::new());
+ }
+ // Read from TemperFS
+ let file_url = format!("{temper_api_url}/tdata/Files('{content_file_id}')/$value");
+ let resp2 = ctx.http_call("GET", &file_url, &headers, "")?;
+ if resp2.status == 200 { Ok(resp2.body) } else { Ok(String::new()) }
+}
+
+/// Load active skills as an XML block for the system prompt.
+fn load_skills_block(
+ ctx: &Context,
+ temper_api_url: &str,
+ tenant: &str,
+) -> Result {
+ let url = format!("{temper_api_url}/tdata/AgentSkills?$filter=Status eq 'Active'");
+ let headers = vec![
+ ("x-tenant-id".to_string(), tenant.to_string()),
+ ("x-temper-principal-kind".to_string(), "admin".to_string()),
+ ("accept".to_string(), "application/json".to_string()),
+ ];
+ let resp = ctx.http_call("GET", &url, &headers, "")?;
+ if resp.status != 200 {
+ return Ok(String::new());
+ }
+ let parsed: Value = serde_json::from_str(&resp.body).unwrap_or(json!({}));
+ let skills = parsed.get("value").and_then(|v| v.as_array()).cloned().unwrap_or_default();
+ if skills.is_empty() {
+ return Ok(String::new());
+ }
+ let mut xml = String::from("\n");
+ for skill in &skills {
+ let name = entity_field_str(skill, &["Name"]).unwrap_or("unknown");
+ let desc = entity_field_str(skill, &["Description"]).unwrap_or("");
+ let file_id = entity_field_str(skill, &["ContentFileId"]).unwrap_or("");
+ xml.push_str(&format!(" \n"));
+ }
+ xml.push_str("");
+ Ok(xml)
+}
+
+/// Load agent memories as a context block for the system prompt.
+fn load_memory_block(
+ ctx: &Context,
+ temper_api_url: &str,
+ tenant: &str,
+ soul_id: &str,
+) -> Result {
+ let url = format!(
+ "{temper_api_url}/tdata/AgentMemorys?$filter=SoulId eq '{}' and Status eq 'Active'",
+ soul_id
+ );
+ let headers = vec![
+ ("x-tenant-id".to_string(), tenant.to_string()),
+ ("x-temper-principal-kind".to_string(), "admin".to_string()),
+ ("accept".to_string(), "application/json".to_string()),
+ ];
+ let resp = ctx.http_call("GET", &url, &headers, "")?;
+ if resp.status != 200 {
+ return Ok(String::new());
+ }
+ let parsed: Value = serde_json::from_str(&resp.body).unwrap_or(json!({}));
+ let memories = parsed.get("value").and_then(|v| v.as_array()).cloned().unwrap_or_default();
+ if memories.is_empty() {
+ return Ok(String::new());
+ }
+ let mut block = String::from("\n");
+ for mem in &memories {
+ let key = entity_field_str(mem, &["Key"]).unwrap_or("unknown");
+ let content = entity_field_str(mem, &["Content"]).unwrap_or("");
+ let mem_type = entity_field_str(mem, &["MemoryType"]).unwrap_or("reference");
+ block.push_str(&format!(" \n {content}\n \n"));
+ }
+ block.push_str("");
+ Ok(block)
+}
+
+fn direct_field_str<'a>(value: &'a Value, keys: &[&str]) -> Option<&'a str> {
+ keys.iter()
+ .find_map(|key| value.get(*key).and_then(Value::as_str))
+}
+
+fn entity_field_str<'a>(value: &'a Value, keys: &[&str]) -> Option<&'a str> {
+ direct_field_str(value, keys).or_else(|| {
+ value.get("fields")
+ .and_then(|fields| direct_field_str(fields, keys))
+ })
+}
diff --git a/os-apps/temper-agent/wasm/sandbox_provisioner/src/lib.rs b/os-apps/temper-agent/wasm/sandbox_provisioner/src/lib.rs
index 25e98a18..d10b3044 100644
--- a/os-apps/temper-agent/wasm/sandbox_provisioner/src/lib.rs
+++ b/os-apps/temper-agent/wasm/sandbox_provisioner/src/lib.rs
@@ -52,10 +52,14 @@ pub extern "C" fn run(_ctx_ptr: i32, _ctx_len: i32) -> i32 {
let tenant = &ctx.tenant;
- let fs_result = create_conversation_storage(&ctx, &temper_api_url, tenant, entity_id);
+ let fs_result =
+ create_conversation_storage(&ctx, &temper_api_url, tenant, entity_id, user_message);
- let (workspace_id, conversation_file_id, file_manifest_id) = match fs_result {
- Ok((ws, conv, manifest)) => (ws, conv, manifest),
+ let (workspace_id, conversation_file_id, file_manifest_id, session_file_id, session_leaf_id) =
+ match fs_result {
+ Ok((ws, conv, manifest, session_file_id, session_leaf_id)) => {
+ (ws, conv, manifest, session_file_id, session_leaf_id)
+ }
Err(e) => {
ctx.log(
"warn",
@@ -63,7 +67,13 @@ pub extern "C" fn run(_ctx_ptr: i32, _ctx_len: i32) -> i32 {
"sandbox_provisioner: TemperFS setup failed: {e}, falling back to inline"
),
);
- (String::new(), String::new(), String::new())
+ (
+ String::new(),
+ String::new(),
+ String::new(),
+ String::new(),
+ String::new(),
+ )
}
};
@@ -76,6 +86,8 @@ pub extern "C" fn run(_ctx_ptr: i32, _ctx_len: i32) -> i32 {
"workspace_id": workspace_id,
"conversation_file_id": conversation_file_id,
"file_manifest_id": file_manifest_id,
+ "session_file_id": session_file_id,
+ "session_leaf_id": session_leaf_id,
}),
);
@@ -220,18 +232,19 @@ fn provision_sandbox(ctx: &Context) -> Result {
})
}
-/// Create a TemperFS Workspace, conversation File, and manifest File.
-/// Returns (workspace_entity_id, conversation_file_id, manifest_file_id).
+/// Create a TemperFS Workspace, conversation File, manifest File, and session file.
+/// Returns (workspace_entity_id, conversation_file_id, manifest_file_id, session_file_id, session_leaf_id).
fn create_conversation_storage(
ctx: &Context,
temper_api_url: &str,
tenant: &str,
agent_id: &str,
-) -> Result<(String, String, String), String> {
+ user_message: &str,
+) -> Result<(String, String, String, String, String), String> {
let headers = vec![
("content-type".to_string(), "application/json".to_string()),
("x-tenant-id".to_string(), tenant.to_string()),
- ("x-temper-principal-kind".to_string(), "system".to_string()),
+ ("x-temper-principal-kind".to_string(), "admin".to_string()),
];
// 1. Create Workspace
@@ -305,7 +318,7 @@ fn create_conversation_storage(
let value_headers = vec![
("content-type".to_string(), "application/json".to_string()),
("x-tenant-id".to_string(), tenant.to_string()),
- ("x-temper-principal-kind".to_string(), "system".to_string()),
+ ("x-temper-principal-kind".to_string(), "admin".to_string()),
];
let value_resp = ctx.http_call("PUT", &value_url, &value_headers, &init_conv)?;
@@ -367,5 +380,121 @@ fn create_conversation_storage(
);
}
- Ok((workspace_id, file_id, manifest_id))
+ let (session_file_id, session_leaf_id) =
+ create_session_tree(ctx, temper_api_url, tenant, &workspace_id, agent_id, user_message);
+
+ Ok((
+ workspace_id,
+ file_id,
+ manifest_id,
+ session_file_id,
+ session_leaf_id,
+ ))
+}
+
+/// Create a session tree JSONL file in TemperFS.
+/// Returns (session_file_id, session_leaf_id). Non-fatal on failure.
+fn create_session_tree(
+ ctx: &Context,
+ temper_api_url: &str,
+ tenant: &str,
+ workspace_id: &str,
+ agent_id: &str,
+ user_message: &str,
+) -> (String, String) {
+ let headers = vec![
+ ("content-type".to_string(), "application/json".to_string()),
+ ("x-tenant-id".to_string(), tenant.to_string()),
+ ("x-temper-principal-kind".to_string(), "admin".to_string()),
+ ];
+
+ // Create session JSONL file in TemperFS
+ let session_file_body = json!({
+ "FileId": format!("session-{agent_id}"),
+ "workspace_id": workspace_id,
+ "name": "session.jsonl",
+ "mime_type": "text/plain",
+ "path": "/session.jsonl"
+ });
+ let session_file_resp = match ctx.http_call(
+ "POST",
+ &format!("{temper_api_url}/tdata/Files"),
+ &headers,
+ &serde_json::to_string(&session_file_body).unwrap_or_default(),
+ ) {
+ Ok(resp) => resp,
+ Err(e) => {
+ ctx.log("warn", &format!("Failed to create session file: {e}"));
+ return (String::new(), String::new());
+ }
+ };
+
+ let session_file_id = if session_file_resp.status >= 200 && session_file_resp.status < 300 {
+ let parsed: Value =
+ serde_json::from_str(&session_file_resp.body).unwrap_or(json!({}));
+ parsed
+ .get("entity_id")
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .to_string()
+ } else {
+ ctx.log(
+ "warn",
+ &format!(
+ "Failed to create session file (HTTP {})",
+ session_file_resp.status
+ ),
+ );
+ return (String::new(), String::new());
+ };
+
+ if session_file_id.is_empty() {
+ return (String::new(), String::new());
+ }
+
+ // Initialize session file with JSONL header + first user message
+ let header_id = format!("h-{agent_id}");
+ let header_entry = json!({
+ "id": header_id,
+ "parentId": null,
+ "type": "header",
+ "version": 1,
+ "tokens": 0
+ });
+ let header_line = serde_json::to_string(&header_entry).unwrap_or_default();
+
+ let session_leaf_id = format!("u-{agent_id}-0");
+ let user_entry = json!({
+ "id": session_leaf_id,
+ "parentId": header_id,
+ "type": "message",
+ "role": "user",
+ "content": user_message,
+ "tokens": user_message.len() / 4
+ });
+ let user_line = serde_json::to_string(&user_entry).unwrap_or_default();
+ let initial_jsonl = format!("{header_line}\n{user_line}");
+
+ let write_url = format!("{temper_api_url}/tdata/Files('{session_file_id}')/$value");
+ let write_headers = vec![
+ ("content-type".to_string(), "text/plain".to_string()),
+ ("x-tenant-id".to_string(), tenant.to_string()),
+ ("x-temper-principal-kind".to_string(), "admin".to_string()),
+ ];
+ match ctx.http_call("PUT", &write_url, &write_headers, &initial_jsonl) {
+ Ok(resp) if resp.status >= 200 && resp.status < 300 => {
+ ctx.log("info", "sandbox_provisioner: session tree initialized");
+ }
+ Ok(resp) => {
+ ctx.log(
+ "warn",
+ &format!("Failed to write session file (HTTP {})", resp.status),
+ );
+ }
+ Err(e) => {
+ ctx.log("warn", &format!("Failed to write session file: {e}"));
+ }
+ }
+
+ (session_file_id, session_leaf_id)
}
diff --git a/os-apps/temper-agent/wasm/session-tree-lib/Cargo.lock b/os-apps/temper-agent/wasm/session-tree-lib/Cargo.lock
new file mode 100644
index 00000000..2d459393
--- /dev/null
+++ b/os-apps/temper-agent/wasm/session-tree-lib/Cargo.lock
@@ -0,0 +1,105 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.149"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "session-tree-lib"
+version = "0.1.0"
+dependencies = [
+ "serde_json",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/os-apps/temper-agent/wasm/session-tree-lib/Cargo.toml b/os-apps/temper-agent/wasm/session-tree-lib/Cargo.toml
new file mode 100644
index 00000000..0f33fe77
--- /dev/null
+++ b/os-apps/temper-agent/wasm/session-tree-lib/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "session-tree-lib"
+version = "0.1.0"
+edition = "2024"
+
+[lib]
+crate-type = ["rlib"]
+
+[workspace]
+
+[dependencies]
+serde_json = "1"
diff --git a/os-apps/temper-agent/wasm/session-tree-lib/src/lib.rs b/os-apps/temper-agent/wasm/session-tree-lib/src/lib.rs
new file mode 100644
index 00000000..4c814343
--- /dev/null
+++ b/os-apps/temper-agent/wasm/session-tree-lib/src/lib.rs
@@ -0,0 +1,484 @@
+//! Session Tree Library — shared JSONL tree operations for TemperAgent WASM modules.
+//!
+//! Provides append-only tree-structured conversation storage with branching,
+//! compaction support, and leaf-to-root context assembly.
+//!
+//! Storage format: JSONL (one JSON object per line) with tree structure via id/parentId.
+
+use std::collections::BTreeMap;
+use serde_json::{Value, json};
+
+/// A single entry in the session tree.
+#[derive(Debug, Clone)]
+pub struct SessionEntry {
+ pub id: String,
+ pub parent_id: Option,
+ pub entry_type: EntryType,
+ pub data: Value,
+ pub tokens: usize,
+}
+
+/// Type of session tree entry.
+#[derive(Debug, Clone, PartialEq)]
+pub enum EntryType {
+ /// Session header with metadata.
+ Header,
+ /// A conversation message (user, assistant, or tool_result).
+ Message,
+ /// A compaction summary replacing older messages.
+ Compaction,
+ /// A steering injection point.
+ Steering,
+}
+
+impl EntryType {
+ pub fn as_str(&self) -> &str {
+ match self {
+ EntryType::Header => "header",
+ EntryType::Message => "message",
+ EntryType::Compaction => "compaction",
+ EntryType::Steering => "steering",
+ }
+ }
+
+ pub fn from_str(s: &str) -> Self {
+ match s {
+ "header" => EntryType::Header,
+ "message" => EntryType::Message,
+ "compaction" => EntryType::Compaction,
+ "steering" => EntryType::Steering,
+ _ => EntryType::Message,
+ }
+ }
+}
+
+/// The session tree — an append-only tree of conversation entries.
+pub struct SessionTree {
+ entries: BTreeMap,
+ /// Ordered list of entry IDs (insertion order).
+ order: Vec,
+ /// Raw JSONL lines for serialization.
+ raw_lines: Vec,
+}
+
+impl SessionTree {
+ /// Parse a JSONL string into a SessionTree.
+ pub fn from_jsonl(data: &str) -> Self {
+ let mut entries = BTreeMap::new();
+ let mut order = Vec::new();
+ let mut raw_lines = Vec::new();
+
+ for line in data.lines() {
+ let line = line.trim();
+ if line.is_empty() {
+ continue;
+ }
+ raw_lines.push(line.to_string());
+
+ if let Ok(val) = serde_json::from_str::(line) {
+ let id = val.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string();
+ let parent_id = val.get("parentId").and_then(|v| v.as_str()).map(|s| s.to_string());
+ let entry_type = val.get("type").and_then(|v| v.as_str()).map(EntryType::from_str).unwrap_or(EntryType::Message);
+ let tokens = val.get("tokens").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
+
+ if !id.is_empty() {
+ let entry = SessionEntry {
+ id: id.clone(),
+ parent_id,
+ entry_type,
+ data: val,
+ tokens,
+ };
+ order.push(id.clone());
+ entries.insert(id, entry);
+ }
+ }
+ }
+
+ SessionTree { entries, order, raw_lines }
+ }
+
+ /// Create an empty session tree with a header entry.
+ pub fn new(session_id: &str) -> Self {
+ let header = json!({
+ "id": format!("h-{session_id}"),
+ "parentId": null,
+ "type": "header",
+ "version": 1,
+ "created": "",
+ "tokens": 0
+ });
+ let header_line = serde_json::to_string(&header).unwrap_or_default();
+ let id = format!("h-{session_id}");
+
+ let entry = SessionEntry {
+ id: id.clone(),
+ parent_id: None,
+ entry_type: EntryType::Header,
+ data: header,
+ tokens: 0,
+ };
+
+ let mut entries = BTreeMap::new();
+ entries.insert(id.clone(), entry);
+
+ SessionTree {
+ entries,
+ order: vec![id],
+ raw_lines: vec![header_line],
+ }
+ }
+
+ /// Check if the tree is empty (no entries at all).
+ pub fn is_empty(&self) -> bool {
+ self.entries.is_empty()
+ }
+
+ /// Get the number of entries.
+ pub fn len(&self) -> usize {
+ self.entries.len()
+ }
+
+ /// Get an entry by ID.
+ pub fn get(&self, id: &str) -> Option<&SessionEntry> {
+ self.entries.get(id)
+ }
+
+ /// Find the last entry ID (the most recently appended).
+ pub fn last_entry_id(&self) -> Option<&str> {
+ self.order.last().map(|s| s.as_str())
+ }
+
+ /// Build context messages by walking from leaf_id to root.
+ /// Handles compaction entries: when a compaction is encountered,
+ /// it replaces all entries before it with the summary.
+ pub fn build_context(&self, leaf_id: &str) -> Vec {
+ // Walk from leaf to root collecting entries
+ let mut chain: Vec<&SessionEntry> = Vec::new();
+ let mut current_id = Some(leaf_id.to_string());
+
+ while let Some(id) = current_id {
+ if let Some(entry) = self.entries.get(&id) {
+ chain.push(entry);
+ current_id = entry.parent_id.clone();
+ } else {
+ break;
+ }
+ }
+
+ // Reverse to get root-to-leaf order
+ chain.reverse();
+
+ // Build messages, handling compaction entries
+ let mut messages: Vec = Vec::new();
+
+ for entry in &chain {
+ match entry.entry_type {
+ EntryType::Header => {
+ // Skip headers — they're metadata
+ continue;
+ }
+ EntryType::Compaction => {
+ // A compaction replaces all prior messages with its summary
+ messages.clear();
+ if let Some(summary) = entry.data.get("summary").and_then(|v| v.as_str()) {
+ messages.push(json!({
+ "role": "user",
+ "content": format!("[Previous conversation summary]\n{summary}")
+ }));
+ }
+ }
+ EntryType::Message | EntryType::Steering => {
+ // Extract role and content from the entry
+ let role = entry.data.get("role").and_then(|v| v.as_str()).unwrap_or("user");
+ if let Some(content) = entry.data.get("content").cloned() {
+ messages.push(json!({
+ "role": role,
+ "content": content,
+ }));
+ }
+ }
+ }
+ }
+
+ messages
+ }
+
+ /// Append a new entry to the tree. Returns the JSONL line for the new entry.
+ /// The entry is added with the given parent_id.
+ pub fn append_entry(
+ &mut self,
+ id: &str,
+ parent_id: Option<&str>,
+ entry_type: EntryType,
+ role: Option<&str>,
+ content: Option<&Value>,
+ tokens: usize,
+ extra_fields: Option<&Value>,
+ ) -> String {
+ let mut data = json!({
+ "id": id,
+ "parentId": parent_id,
+ "type": entry_type.as_str(),
+ "tokens": tokens,
+ });
+
+ if let Some(role) = role {
+ data["role"] = json!(role);
+ }
+ if let Some(content) = content {
+ data["content"] = content.clone();
+ }
+ if let Some(extra) = extra_fields {
+ if let Some(obj) = extra.as_object() {
+ for (k, v) in obj {
+ data[k] = v.clone();
+ }
+ }
+ }
+
+ let line = serde_json::to_string(&data).unwrap_or_default();
+
+ let entry = SessionEntry {
+ id: id.to_string(),
+ parent_id: parent_id.map(|s| s.to_string()),
+ entry_type,
+ data,
+ tokens,
+ };
+
+ self.order.push(id.to_string());
+ self.entries.insert(id.to_string(), entry);
+ self.raw_lines.push(line.clone());
+
+ line
+ }
+
+ /// Append a user message. Returns (entry_id, jsonl_line).
+ pub fn append_user_message(&mut self, parent_id: &str, content: &str, tokens: usize) -> (String, String) {
+ let id = format!("u-{}", self.order.len());
+ let line = self.append_entry(
+ &id,
+ Some(parent_id),
+ EntryType::Message,
+ Some("user"),
+ Some(&json!(content)),
+ tokens,
+ None,
+ );
+ (id, line)
+ }
+
+ /// Append an assistant message. Returns (entry_id, jsonl_line).
+ pub fn append_assistant_message(&mut self, parent_id: &str, content: &Value, tokens: usize) -> (String, String) {
+ let id = format!("a-{}", self.order.len());
+ let line = self.append_entry(
+ &id,
+ Some(parent_id),
+ EntryType::Message,
+ Some("assistant"),
+ Some(content),
+ tokens,
+ None,
+ );
+ (id, line)
+ }
+
+ /// Append a tool result message (role: user with tool_result content). Returns (entry_id, jsonl_line).
+ pub fn append_tool_results(&mut self, parent_id: &str, tool_results: &Value, tokens: usize) -> (String, String) {
+ let id = format!("t-{}", self.order.len());
+ let line = self.append_entry(
+ &id,
+ Some(parent_id),
+ EntryType::Message,
+ Some("user"),
+ Some(tool_results),
+ tokens,
+ None,
+ );
+ (id, line)
+ }
+
+ /// Append a compaction entry. Returns (entry_id, jsonl_line).
+ pub fn append_compaction(&mut self, parent_id: &str, summary: &str, first_kept: &str) -> (String, String) {
+ let id = format!("c-{}", self.order.len());
+ let extra = json!({
+ "summary": summary,
+ "first_kept": first_kept,
+ });
+ let line = self.append_entry(
+ &id,
+ Some(parent_id),
+ EntryType::Compaction,
+ None,
+ None,
+ 0,
+ Some(&extra),
+ );
+ (id, line)
+ }
+
+ /// Append a steering message. Returns (entry_id, jsonl_line).
+ pub fn append_steering_message(&mut self, parent_id: &str, content: &str, tokens: usize) -> (String, String) {
+ let id = format!("s-{}", self.order.len());
+ let line = self.append_entry(
+ &id,
+ Some(parent_id),
+ EntryType::Steering,
+ Some("user"),
+ Some(&json!(content)),
+ tokens,
+ None,
+ );
+ (id, line)
+ }
+
+ /// Estimate total tokens in the context for a given leaf.
+ pub fn estimate_tokens(&self, leaf_id: &str) -> usize {
+ let mut total = 0;
+ let mut current_id = Some(leaf_id.to_string());
+
+ while let Some(id) = current_id {
+ if let Some(entry) = self.entries.get(&id) {
+ if entry.entry_type == EntryType::Compaction {
+ // After compaction, only count from here forward
+ total += entry.tokens;
+ break;
+ }
+ total += entry.tokens;
+ current_id = entry.parent_id.clone();
+ } else {
+ break;
+ }
+ }
+
+ total
+ }
+
+ /// Find a cut point for compaction. Returns the entry ID where we should
+ /// start keeping messages (everything before this gets compacted).
+ /// Walks backward from the leaf keeping `keep_recent_tokens` worth of messages.
+ pub fn find_cut_point(&self, leaf_id: &str, keep_recent_tokens: usize) -> Option {
+ let mut accumulated = 0;
+ let mut current_id = Some(leaf_id.to_string());
+ let mut cut_point = None;
+
+ while let Some(id) = current_id {
+ if let Some(entry) = self.entries.get(&id) {
+ accumulated += entry.tokens;
+ if accumulated >= keep_recent_tokens {
+ // This is where we should cut — keep everything after this
+ cut_point = Some(id.clone());
+ break;
+ }
+ current_id = entry.parent_id.clone();
+ } else {
+ break;
+ }
+ }
+
+ cut_point
+ }
+
+ /// Serialize the tree back to JSONL format.
+ pub fn to_jsonl(&self) -> String {
+ self.raw_lines.join("\n")
+ }
+
+ /// Get all entry IDs in insertion order.
+ pub fn entry_ids(&self) -> &[String] {
+ &self.order
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_new_session_tree() {
+ let tree = SessionTree::new("test-1");
+ assert_eq!(tree.len(), 1);
+ assert!(!tree.is_empty());
+ }
+
+ #[test]
+ fn test_append_and_build_context() {
+ let mut tree = SessionTree::new("test-1");
+ let header_id = tree.last_entry_id().unwrap().to_string();
+
+ let (user_id, _) = tree.append_user_message(&header_id, "Hello", 10);
+ let (asst_id, _) = tree.append_assistant_message(&user_id, &json!([{"type": "text", "text": "Hi there!"}]), 20);
+
+ let messages = tree.build_context(&asst_id);
+ assert_eq!(messages.len(), 2);
+ assert_eq!(messages[0]["role"], "user");
+ assert_eq!(messages[1]["role"], "assistant");
+ }
+
+ #[test]
+ fn test_compaction() {
+ let mut tree = SessionTree::new("test-1");
+ let header_id = tree.last_entry_id().unwrap().to_string();
+
+ let (u1, _) = tree.append_user_message(&header_id, "First message", 100);
+ let (a1, _) = tree.append_assistant_message(&u1, &json!("Response 1"), 200);
+ let (compact_id, _) = tree.append_compaction(&a1, "Summary of conversation so far", &a1);
+ let (u2, _) = tree.append_user_message(&compact_id, "New message after compaction", 50);
+
+ let messages = tree.build_context(&u2);
+ // Should have: compaction summary + new message
+ assert_eq!(messages.len(), 2);
+ assert!(messages[0]["content"].as_str().unwrap().contains("summary"));
+ }
+
+ #[test]
+ fn test_from_jsonl() {
+ let jsonl = r#"{"id":"h-1","parentId":null,"type":"header","version":1,"tokens":0}
+{"id":"u-1","parentId":"h-1","type":"message","role":"user","content":"Hello","tokens":10}
+{"id":"a-1","parentId":"u-1","type":"message","role":"assistant","content":"Hi!","tokens":5}"#;
+
+ let tree = SessionTree::from_jsonl(jsonl);
+ assert_eq!(tree.len(), 3);
+
+ let messages = tree.build_context("a-1");
+ assert_eq!(messages.len(), 2);
+ }
+
+ #[test]
+ fn test_to_jsonl_roundtrip() {
+ let mut tree = SessionTree::new("test-1");
+ let header_id = tree.last_entry_id().unwrap().to_string();
+ tree.append_user_message(&header_id, "Hello", 10);
+
+ let jsonl = tree.to_jsonl();
+ let tree2 = SessionTree::from_jsonl(&jsonl);
+ assert_eq!(tree2.len(), tree.len());
+ }
+
+ #[test]
+ fn test_estimate_tokens() {
+ let mut tree = SessionTree::new("test-1");
+ let header_id = tree.last_entry_id().unwrap().to_string();
+
+ let (u1, _) = tree.append_user_message(&header_id, "Hello", 100);
+ let (a1, _) = tree.append_assistant_message(&u1, &json!("Response"), 200);
+
+ assert_eq!(tree.estimate_tokens(&a1), 300);
+ }
+
+ #[test]
+ fn test_find_cut_point() {
+ let mut tree = SessionTree::new("test-1");
+ let header_id = tree.last_entry_id().unwrap().to_string();
+
+ let (u1, _) = tree.append_user_message(&header_id, "Msg 1", 100);
+ let (a1, _) = tree.append_assistant_message(&u1, &json!("Resp 1"), 200);
+ let (u2, _) = tree.append_user_message(&a1, "Msg 2", 100);
+ let (a2, _) = tree.append_assistant_message(&u2, &json!("Resp 2"), 200);
+
+ // Keep 250 tokens — should cut somewhere in the middle
+ let cut = tree.find_cut_point(&a2, 250);
+ assert!(cut.is_some());
+ }
+}
diff --git a/os-apps/temper-agent/wasm/steering_checker/Cargo.lock b/os-apps/temper-agent/wasm/steering_checker/Cargo.lock
new file mode 100644
index 00000000..5f287e76
--- /dev/null
+++ b/os-apps/temper-agent/wasm/steering_checker/Cargo.lock
@@ -0,0 +1,129 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.149"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "session-tree-lib"
+version = "0.1.0"
+dependencies = [
+ "serde_json",
+]
+
+[[package]]
+name = "steering-checker"
+version = "0.1.0"
+dependencies = [
+ "session-tree-lib",
+ "temper-wasm-sdk",
+ "wasm-helpers",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "temper-wasm-sdk"
+version = "0.1.0"
+dependencies = [
+ "serde_json",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "wasm-helpers"
+version = "0.1.0"
+dependencies = [
+ "serde_json",
+ "temper-wasm-sdk",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/os-apps/temper-agent/wasm/steering_checker/Cargo.toml b/os-apps/temper-agent/wasm/steering_checker/Cargo.toml
new file mode 100644
index 00000000..37580684
--- /dev/null
+++ b/os-apps/temper-agent/wasm/steering_checker/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "steering-checker"
+version = "0.1.0"
+edition = "2024"
+
+[lib]
+crate-type = ["cdylib"]
+
+[workspace]
+
+[dependencies]
+temper-wasm-sdk = { path = "../../../../crates/temper-wasm-sdk" }
+session-tree-lib = { path = "../session-tree-lib" }
+wasm-helpers = { path = "../wasm-helpers" }
diff --git a/os-apps/temper-agent/wasm/steering_checker/src/lib.rs b/os-apps/temper-agent/wasm/steering_checker/src/lib.rs
new file mode 100644
index 00000000..28917ed4
--- /dev/null
+++ b/os-apps/temper-agent/wasm/steering_checker/src/lib.rs
@@ -0,0 +1,198 @@
+//! Steering Checker — WASM module for the two-loop steering architecture.
+//!
+//! When the LLM returns end_turn, this module is triggered (via CheckSteering).
+//! It checks for queued steering messages and either:
+//! - Injects the first queued message and returns ContinueWithSteering
+//! - Returns FinalizeResult if no messages are queued
+//!
+//! Build: `cargo build --target wasm32-unknown-unknown --release`
+
+use session_tree_lib::SessionTree;
+use temper_wasm_sdk::prelude::*;
+use wasm_helpers::{read_session_from_temperfs, resolve_temper_api_url, write_session_to_temperfs};
+
+/// Entry point.
+#[unsafe(no_mangle)]
+pub extern "C" fn run(_ctx_ptr: i32, _ctx_len: i32) -> i32 {
+ let result = (|| -> Result<(), String> {
+ let ctx = Context::from_host()?;
+ ctx.log("info", "steering_checker: starting");
+
+ let fields = ctx.entity_state.get("fields").cloned().unwrap_or(json!({}));
+
+ // Read steering state
+ let steering_messages_json = fields
+ .get("steering_messages")
+ .and_then(|v| v.as_str())
+ .unwrap_or("[]");
+
+ let mut steering_messages: Vec = serde_json::from_str(steering_messages_json)
+ .unwrap_or_default();
+
+ let follow_up_count = fields
+ .get("follow_up_count")
+ .and_then(|v| v.as_i64())
+ .unwrap_or(0);
+ let max_follow_ups: i64 = fields
+ .get("max_follow_ups")
+ .and_then(|v| v.as_str())
+ .and_then(|s| s.parse().ok())
+ .unwrap_or(5);
+
+ let session_file_id = fields
+ .get("session_file_id")
+ .and_then(|v| v.as_str())
+ .unwrap_or("");
+ let session_leaf_id = fields
+ .get("session_leaf_id")
+ .and_then(|v| v.as_str())
+ .unwrap_or("");
+
+ let temper_api_url = resolve_temper_api_url(&ctx, &fields);
+ let tenant = &ctx.tenant;
+
+ // Check if we have steering messages AND haven't hit the follow-up limit
+ if !steering_messages.is_empty() && follow_up_count < max_follow_ups {
+ // Dequeue the first steering message
+ let msg = steering_messages.remove(0);
+ let msg_content = msg.get("content")
+ .and_then(|v| v.as_str())
+ .unwrap_or_else(|| msg.as_str().unwrap_or(""));
+
+ ctx.log("info", &format!(
+ "steering_checker: injecting steering message ({} remaining, follow_up {}/{})",
+ steering_messages.len(), follow_up_count + 1, max_follow_ups
+ ));
+
+ // If session tree mode, inject into session tree
+ if !session_file_id.is_empty() && !session_leaf_id.is_empty() {
+ let session_jsonl = read_session_from_temperfs(&ctx, &temper_api_url, tenant, session_file_id)?;
+ let mut tree = SessionTree::from_jsonl(&session_jsonl);
+
+ // Append steering message as a user message in the tree
+ let (new_leaf_id, _line) = tree.append_steering_message(
+ session_leaf_id,
+ msg_content,
+ estimate_tokens(msg_content),
+ );
+
+ // Write back
+ let updated_jsonl = tree.to_jsonl();
+ write_session_to_temperfs(&ctx, &temper_api_url, tenant, session_file_id, &updated_jsonl)?;
+
+ // Update steering_messages in entity state (remove dequeued message)
+ let updated_queue =
+ serde_json::to_string(&steering_messages).unwrap_or_else(|_| "[]".to_string());
+ set_success_result("ContinueWithSteering", &json!({
+ "session_leaf_id": new_leaf_id,
+ "steering_messages": updated_queue,
+ }));
+ } else {
+ // Inline conversation mode (legacy fallback)
+ let conversation_json = fields
+ .get("conversation")
+ .and_then(|v| v.as_str())
+ .unwrap_or("[]");
+ let mut messages: Vec = serde_json::from_str(conversation_json).unwrap_or_default();
+ messages.push(json!({
+ "role": "user",
+ "content": msg_content,
+ }));
+ let updated_conversation = serde_json::to_string(&messages).unwrap_or_default();
+
+ set_success_result("ContinueWithSteering", &json!({
+ "conversation": updated_conversation,
+ "steering_messages": serde_json::to_string(&steering_messages)
+ .unwrap_or_else(|_| "[]".to_string()),
+ }));
+ }
+ } else {
+ // No steering messages or follow-up limit reached — finalize
+ if follow_up_count >= max_follow_ups {
+ ctx.log("info", &format!(
+ "steering_checker: follow-up limit reached ({}/{}), finalizing",
+ follow_up_count, max_follow_ups
+ ));
+ } else {
+ ctx.log("info", "steering_checker: no steering messages, finalizing");
+ }
+
+ // Extract the result text from the last assistant message
+ let result_text = extract_last_result(&ctx, &fields, &temper_api_url, tenant, session_file_id, session_leaf_id)?;
+
+ set_success_result("FinalizeResult", &json!({
+ "result": result_text,
+ "session_leaf_id": session_leaf_id,
+ }));
+ }
+
+ Ok(())
+ })();
+
+ if let Err(e) = result {
+ set_error_result(&e);
+ }
+ 0
+}
+
+/// Extract the last assistant text from the conversation for the result field.
+fn extract_last_result(
+ ctx: &Context,
+ fields: &Value,
+ temper_api_url: &str,
+ tenant: &str,
+ session_file_id: &str,
+ session_leaf_id: &str,
+) -> Result {
+ if !session_file_id.is_empty() && !session_leaf_id.is_empty() {
+ let session_jsonl = read_session_from_temperfs(ctx, temper_api_url, tenant, session_file_id)?;
+ let tree = SessionTree::from_jsonl(&session_jsonl);
+ let messages = tree.build_context(session_leaf_id);
+
+ // Find last assistant message
+ for msg in messages.iter().rev() {
+ if msg.get("role").and_then(|v| v.as_str()) == Some("assistant") {
+ return Ok(extract_text_from_content(msg.get("content")));
+ }
+ }
+ Ok(String::new())
+ } else {
+ let conversation_json = fields
+ .get("conversation")
+ .and_then(|v| v.as_str())
+ .unwrap_or("[]");
+ let messages: Vec = serde_json::from_str(conversation_json).unwrap_or_default();
+
+ for msg in messages.iter().rev() {
+ if msg.get("role").and_then(|v| v.as_str()) == Some("assistant") {
+ return Ok(extract_text_from_content(msg.get("content")));
+ }
+ }
+ Ok(String::new())
+ }
+}
+
+/// Extract text from an assistant message content (handles both string and array formats).
+fn extract_text_from_content(content: Option<&Value>) -> String {
+ match content {
+ Some(Value::String(s)) => s.clone(),
+ Some(Value::Array(arr)) => {
+ arr.iter()
+ .filter_map(|block| {
+ if block.get("type").and_then(|v| v.as_str()) == Some("text") {
+ block.get("text").and_then(|v| v.as_str()).map(String::from)
+ } else {
+ None
+ }
+ })
+ .collect::>()
+ .join("\n")
+ }
+ _ => String::new(),
+ }
+}
+
+/// Simple token estimate (4 chars per token).
+fn estimate_tokens(text: &str) -> usize {
+ text.len() / 4
+}
diff --git a/os-apps/temper-agent/wasm/tool_runner/Cargo.lock b/os-apps/temper-agent/wasm/tool_runner/Cargo.lock
index e03b1945..a00a7bc1 100644
--- a/os-apps/temper-agent/wasm/tool_runner/Cargo.lock
+++ b/os-apps/temper-agent/wasm/tool_runner/Cargo.lock
@@ -74,6 +74,13 @@ dependencies = [
"zmij",
]
+[[package]]
+name = "session-tree-lib"
+version = "0.1.0"
+dependencies = [
+ "serde_json",
+]
+
[[package]]
name = "syn"
version = "2.0.117"
@@ -96,6 +103,7 @@ dependencies = [
name = "tool-runner"
version = "0.1.0"
dependencies = [
+ "session-tree-lib",
"temper-wasm-sdk",
]
diff --git a/os-apps/temper-agent/wasm/tool_runner/Cargo.toml b/os-apps/temper-agent/wasm/tool_runner/Cargo.toml
index bc231ee4..4812213d 100644
--- a/os-apps/temper-agent/wasm/tool_runner/Cargo.toml
+++ b/os-apps/temper-agent/wasm/tool_runner/Cargo.toml
@@ -10,3 +10,4 @@ crate-type = ["cdylib"]
[dependencies]
temper-wasm-sdk = { path = "../../../../crates/temper-wasm-sdk" }
+session-tree-lib = { path = "../session-tree-lib" }
diff --git a/os-apps/temper-agent/wasm/tool_runner/src/lib.rs b/os-apps/temper-agent/wasm/tool_runner/src/lib.rs
index 17ff715e..6814290c 100644
--- a/os-apps/temper-agent/wasm/tool_runner/src/lib.rs
+++ b/os-apps/temper-agent/wasm/tool_runner/src/lib.rs
@@ -22,15 +22,28 @@ pub extern "C" fn run(_ctx_ptr: i32, _ctx_len: i32) -> i32 {
.and_then(|v| v.as_str())
.unwrap_or("");
- if sandbox_url.is_empty() {
- return Err("sandbox_url is empty — cannot execute tools".to_string());
- }
-
let workdir = fields
.get("workdir")
.and_then(|v| v.as_str())
.unwrap_or("/workspace");
+ // Temper API URL: read from integration config, default to localhost
+ let temper_api_url = ctx
+ .config
+ .get("temper_api_url")
+ .cloned()
+ .unwrap_or_else(|| "http://127.0.0.1:3000".to_string());
+ let tenant = &ctx.tenant;
+ let hook_policy = fields
+ .get("hook_policy")
+ .and_then(|v| v.as_str())
+ .unwrap_or("none");
+ let soul_id = fields
+ .get("soul_id")
+ .and_then(|v| v.as_str())
+ .unwrap_or("");
+ let _ = send_heartbeat(&ctx, &temper_api_url, tenant);
+
// Read pending tool calls from trigger params
let tool_calls_json = ctx
.trigger_params
@@ -61,13 +74,56 @@ pub extern "C" fn run(_ctx_ptr: i32, _ctx_len: i32) -> i32 {
"info",
&format!("tool_runner: executing tool '{tool_name}' id={tool_id}"),
);
+ emit_progress_ignore(
+ &ctx,
+ json!({
+ "kind": "tool_execution_start",
+ "message": format!("executing tool {tool_name}"),
+ "tool_call_id": tool_id,
+ "tool_name": tool_name,
+ }),
+ );
- let result = execute_tool(&ctx, sandbox_url, workdir, tool_name, &input);
+ let result = if let Err(error) = validate_tool_input(tool_name, &input) {
+ Err(error)
+ } else if let Some(error) =
+ evaluate_before_hooks(&ctx, &temper_api_url, tenant, soul_id, hook_policy, tool_name)?
+ {
+ Err(error)
+ } else if is_entity_tool(tool_name) {
+ execute_entity_tool(&ctx, &temper_api_url, tenant, &fields, tool_name, &input)
+ } else if sandbox_url.is_empty() {
+ Err(format!("sandbox_url is empty — cannot execute sandbox tool '{tool_name}'"))
+ } else {
+ execute_tool(&ctx, sandbox_url, workdir, tool_name, &input)
+ };
let (content, is_error) = match result {
- Ok(output) => (output, false),
+ Ok(output) => (
+ apply_after_hooks(
+ &ctx,
+ &temper_api_url,
+ tenant,
+ soul_id,
+ hook_policy,
+ tool_name,
+ output,
+ )?,
+ false,
+ ),
Err(e) => (format!("Error: {e}"), true),
};
+ let _ = send_heartbeat(&ctx, &temper_api_url, tenant);
+ emit_progress_ignore(
+ &ctx,
+ json!({
+ "kind": "tool_execution_complete",
+ "message": format!("completed tool {tool_name}"),
+ "tool_call_id": tool_id,
+ "tool_name": tool_name,
+ "is_error": is_error,
+ }),
+ );
tool_results.push(json!({
"type": "tool_result",
@@ -77,45 +133,54 @@ pub extern "C" fn run(_ctx_ptr: i32, _ctx_len: i32) -> i32 {
}));
}
- // TemperFS conversation storage
+ // Session tree and conversation storage
let conversation_file_id = fields
.get("conversation_file_id")
.and_then(|v| v.as_str())
.unwrap_or("");
- // Temper API URL: read from integration config, default to localhost
- let temper_api_url = ctx
- .config
- .get("temper_api_url")
- .cloned()
- .unwrap_or_else(|| "http://127.0.0.1:3000".to_string());
- let tenant = &ctx.tenant;
+ let session_file_id = fields
+ .get("session_file_id")
+ .and_then(|v| v.as_str())
+ .unwrap_or("");
+ let session_leaf_id = fields
+ .get("session_leaf_id")
+ .and_then(|v| v.as_str())
+ .unwrap_or("");
- // Read current conversation and append tool results
- let mut messages: Vec = if !conversation_file_id.is_empty() {
- read_conversation_from_temperfs(&ctx, &temper_api_url, tenant, conversation_file_id)?
- } else {
- let conversation_json = fields
- .get("conversation")
- .and_then(|v| v.as_str())
- .unwrap_or("[]");
- serde_json::from_str(conversation_json).unwrap_or_default()
- };
+ let results_json = serde_json::to_string(&tool_results).unwrap_or_default();
+ let mut params = json!({
+ "pending_tool_calls": results_json,
+ });
- // Append tool results as a user message (Anthropic API format)
- messages.push(json!({
- "role": "user",
- "content": tool_results,
- }));
+ if !session_file_id.is_empty() && !session_leaf_id.is_empty() {
+ // Session tree mode: append tool results
+ let session_jsonl = read_session_from_temperfs(&ctx, &temper_api_url, tenant, session_file_id)?;
+ let mut tree = session_tree_lib::SessionTree::from_jsonl(&session_jsonl);
+ let tool_results_value = json!(tool_results.clone());
+ let tokens_est = results_json.len() / 4;
+ let (new_leaf, _) = tree.append_tool_results(session_leaf_id, &tool_results_value, tokens_est);
+ let updated_jsonl = tree.to_jsonl();
+ write_session_to_temperfs(&ctx, &temper_api_url, tenant, session_file_id, &updated_jsonl)?;
+
+ params["session_leaf_id"] = json!(new_leaf);
+ } else if !conversation_file_id.is_empty() {
+ // Legacy flat JSON mode
+ let mut messages: Vec =
+ read_conversation_from_temperfs(&ctx, &temper_api_url, tenant, conversation_file_id)?;
+
+ // Append tool results as a user message (Anthropic API format)
+ messages.push(json!({
+ "role": "user",
+ "content": tool_results,
+ }));
- // Write back to TemperFS or pass inline
- let updated_conversation = serde_json::to_string(&messages).unwrap_or_default();
- if !conversation_file_id.is_empty() {
+ let updated_conversation = serde_json::to_string(&messages).unwrap_or_default();
let body = format!("{{\"messages\":{updated_conversation}}}");
let url = format!("{temper_api_url}/tdata/Files('{conversation_file_id}')/$value");
let headers = vec![
("content-type".to_string(), "application/json".to_string()),
("x-tenant-id".to_string(), tenant.to_string()),
- ("x-temper-principal-kind".to_string(), "system".to_string()),
+ ("x-temper-principal-kind".to_string(), "admin".to_string()),
];
match ctx.http_call("PUT", &url, &headers, &body) {
Ok(resp) if resp.status >= 200 && resp.status < 300 => {
@@ -138,6 +203,24 @@ pub extern "C" fn run(_ctx_ptr: i32, _ctx_len: i32) -> i32 {
return Err(format!("TemperFS conversation write failed: {e}"));
}
}
+ params["conversation"] = json!(updated_conversation);
+ } else {
+ // Inline conversation mode (no TemperFS)
+ let mut messages: Vec = {
+ let conversation_json = fields
+ .get("conversation")
+ .and_then(|v| v.as_str())
+ .unwrap_or("[]");
+ serde_json::from_str(conversation_json).unwrap_or_default()
+ };
+
+ messages.push(json!({
+ "role": "user",
+ "content": tool_results,
+ }));
+
+ let updated_conversation = serde_json::to_string(&messages).unwrap_or_default();
+ params["conversation"] = json!(updated_conversation);
}
// Fsync sandbox files to TemperFS (best-effort)
@@ -156,7 +239,7 @@ pub extern "C" fn run(_ctx_ptr: i32, _ctx_len: i32) -> i32 {
.unwrap_or(61440);
let sync_exclude = ctx.config.get("sync_exclude").cloned().unwrap_or_default();
- if !file_manifest_id.is_empty() && !workspace_id.is_empty() {
+ if !file_manifest_id.is_empty() && !workspace_id.is_empty() && !sandbox_url.is_empty() {
let e2b = is_e2b_sandbox(sandbox_url);
match sync_files_to_temperfs(
&ctx,
@@ -181,13 +264,6 @@ pub extern "C" fn run(_ctx_ptr: i32, _ctx_len: i32) -> i32 {
}
}
- let results_json = serde_json::to_string(&tool_results).unwrap_or_default();
- let mut params = json!({
- "pending_tool_calls": results_json,
- });
- if conversation_file_id.is_empty() {
- params["conversation"] = json!(updated_conversation);
- }
set_success_result("HandleToolResults", ¶ms);
Ok(())
@@ -846,7 +922,7 @@ fn read_conversation_from_temperfs(
let url = format!("{temper_api_url}/tdata/Files('{file_id}')/$value");
let headers = vec![
("x-tenant-id".to_string(), tenant.to_string()),
- ("x-temper-principal-kind".to_string(), "system".to_string()),
+ ("x-temper-principal-kind".to_string(), "admin".to_string()),
("accept".to_string(), "application/json".to_string()),
];
@@ -976,7 +1052,7 @@ fn read_manifest(
let url = format!("{temper_api_url}/tdata/Files('{manifest_file_id}')/$value");
let headers = vec![
("x-tenant-id".to_string(), tenant.to_string()),
- ("x-temper-principal-kind".to_string(), "system".to_string()),
+ ("x-temper-principal-kind".to_string(), "admin".to_string()),
("accept".to_string(), "application/json".to_string()),
];
@@ -1056,7 +1132,7 @@ fn sync_files_to_temperfs(
let headers = vec![
("content-type".to_string(), "application/json".to_string()),
("x-tenant-id".to_string(), tenant.to_string()),
- ("x-temper-principal-kind".to_string(), "system".to_string()),
+ ("x-temper-principal-kind".to_string(), "admin".to_string()),
];
let file_url = format!("{temper_api_url}/tdata/Files");
@@ -1184,3 +1260,587 @@ fn sync_files_to_temperfs(
Ok(synced_count)
}
+
+// --- Entity tool dispatch ---
+
+fn emit_progress_ignore(ctx: &Context, payload: Value) {
+ let _ = ctx.emit_progress(&payload);
+}
+
+fn send_heartbeat(ctx: &Context, temper_api_url: &str, tenant: &str) -> Result<(), String> {
+ let url = format!(
+ "{temper_api_url}/tdata/TemperAgents('{}')/Temper.Agent.TemperAgent.Heartbeat",
+ ctx.entity_id
+ );
+ let body = json!({ "last_heartbeat_at": "alive" });
+ let _ = ctx.http_call("POST", &url, &odata_headers(tenant), &body.to_string())?;
+ Ok(())
+}
+
+fn validate_tool_input(tool_name: &str, input: &Value) -> Result<(), String> {
+ let object = input
+ .as_object()
+ .ok_or_else(|| format!("{tool_name}: input must be an object"))?;
+ let required: &[&str] = match tool_name {
+ "read" => &["path"],
+ "write" => &["path", "content"],
+ "edit" => &["path", "old_string", "new_string"],
+ "bash" => &["command"],
+ "save_memory" => &["key", "content"],
+ "recall_memory" => &["query"],
+ "spawn_agent" => &["task"],
+ "abort_agent" => &["agent_id"],
+ "steer_agent" => &["agent_id", "message"],
+ "read_entity" => &["file_id"],
+ "run_coding_agent" => &["agent_type", "task"],
+ _ => &[],
+ };
+ for key in required {
+ let Some(value) = object.get(*key) else {
+ return Err(format!("{tool_name}: missing '{key}'"));
+ };
+ if value.is_null() || value.as_str().is_some_and(str::is_empty) {
+ return Err(format!("{tool_name}: '{key}' must not be empty"));
+ }
+ }
+ Ok(())
+}
+
+fn evaluate_before_hooks(
+ ctx: &Context,
+ temper_api_url: &str,
+ tenant: &str,
+ soul_id: &str,
+ hook_policy: &str,
+ tool_name: &str,
+) -> Result