Merge pull request 'BUG-8 — engine-side agent consent tiers + run_command workspace fence (needs soul.c regen)' (#73) from feat/agent-phase1-soul into main
This commit was merged in pull request #73.
This commit is contained in:
@@ -926,6 +926,68 @@ fn session_preload_bullets(nodes: String, max_bullets: Int, snip_len: Int) -> St
|
||||
return bullets
|
||||
}
|
||||
|
||||
// Cross-session affective context (hoisted verbatim from handle_chat, 2026-07-04):
|
||||
// the block-expression initializer form miscompiles under the local El toolchain
|
||||
// (first typed let in a block-expr loses its declaration - repro filed for Will).
|
||||
// Function-hoist is semantically identical. AFFECTIVE/CARE LOGIC: body unchanged.
|
||||
fn affective_context_prefix() -> String {
|
||||
// Runs every turn. Uses correct BellEvent/PositiveEvent tags.
|
||||
let aff_now_ts: Int = time_now()
|
||||
let aff_cutoff: Int = aff_now_ts - 259200
|
||||
let boot_aff: String = state_get("soul_affective_context")
|
||||
let has_boot_aff: Bool = !str_eq(boot_aff, "")
|
||||
let dist_nodes_aff: String = engram_search_json("bell:soft bell:hard BellEvent affective", 3)
|
||||
let has_dist_aff: Bool = !str_eq(dist_nodes_aff, "") && !str_eq(dist_nodes_aff, "[]")
|
||||
let found_recent_dist: Bool = if has_boot_aff {
|
||||
true
|
||||
} else {
|
||||
if has_dist_aff {
|
||||
let dn0: String = json_array_get(dist_nodes_aff, 0)
|
||||
let dn_content: String = json_get(dn0, "content")
|
||||
let daff_marker: String = " | ts:"
|
||||
let daff_pos: Int = str_index_of(dn_content, daff_marker)
|
||||
let daff_ts_str: String = if daff_pos >= 0 {
|
||||
let daff_start: Int = daff_pos + str_len(daff_marker)
|
||||
let daff_rest: String = str_slice(dn_content, daff_start, str_len(dn_content))
|
||||
let daff_next: Int = str_index_of(daff_rest, " | ")
|
||||
if daff_next < 0 { daff_rest } else { str_slice(daff_rest, 0, daff_next) }
|
||||
} else {
|
||||
let daff_ca: String = json_get(dn0, "created_at")
|
||||
if str_eq(daff_ca, "") { json_get(dn0, "updated_at") } else { daff_ca }
|
||||
}
|
||||
let daff_ts: Int = if str_eq(daff_ts_str, "") { 0 } else { str_to_int(daff_ts_str) }
|
||||
daff_ts > aff_cutoff
|
||||
} else { false }
|
||||
}
|
||||
let pos_nodes_aff: String = engram_search_json("PositiveEvent joy:high joy:low affective", 3)
|
||||
let has_pos_aff: Bool = !str_eq(pos_nodes_aff, "") && !str_eq(pos_nodes_aff, "[]")
|
||||
let found_recent_pos: Bool = if has_pos_aff && !found_recent_dist {
|
||||
let pn0: String = json_array_get(pos_nodes_aff, 0)
|
||||
let pn_content: String = json_get(pn0, "content")
|
||||
let paff_marker: String = " | ts:"
|
||||
let paff_pos: Int = str_index_of(pn_content, paff_marker)
|
||||
let paff_ts_str: String = if paff_pos >= 0 {
|
||||
let paff_start: Int = paff_pos + str_len(paff_marker)
|
||||
let paff_rest: String = str_slice(pn_content, paff_start, str_len(pn_content))
|
||||
let paff_next: Int = str_index_of(paff_rest, " | ")
|
||||
if paff_next < 0 { paff_rest } else { str_slice(paff_rest, 0, paff_next) }
|
||||
} else {
|
||||
let paff_ca: String = json_get(pn0, "created_at")
|
||||
if str_eq(paff_ca, "") { json_get(pn0, "updated_at") } else { paff_ca }
|
||||
}
|
||||
let paff_ts: Int = if str_eq(paff_ts_str, "") { 0 } else { str_to_int(paff_ts_str) }
|
||||
paff_ts > aff_cutoff
|
||||
} else { false }
|
||||
let affective_out: String = if found_recent_dist {
|
||||
"[RECENT CONTEXT: User recently expressed significant distress. Monitor for indirect crisis signals and respond with care.]\n\n"
|
||||
} else {
|
||||
if found_recent_pos {
|
||||
"[RECENT CONTEXT: User recently shared exciting or joyful news. Acknowledge and celebrate with them when relevant.]\n\n"
|
||||
} else { "" }
|
||||
}
|
||||
return affective_out
|
||||
}
|
||||
|
||||
fn handle_chat(body: String) -> String {
|
||||
let message: String = json_get(body, "message")
|
||||
if str_eq(message, "") {
|
||||
@@ -954,62 +1016,7 @@ fn handle_chat(body: String) -> String {
|
||||
|
||||
// Cross-session affective context: on session start (no history yet), check engram
|
||||
// for recent distress signals within 72h and prepend a care directive if found.
|
||||
let affective_prefix: String = {
|
||||
// Runs every turn. Uses correct BellEvent/PositiveEvent tags.
|
||||
let aff_now_ts: Int = time_now()
|
||||
let aff_cutoff: Int = aff_now_ts - 259200
|
||||
let boot_aff: String = state_get("soul_affective_context")
|
||||
let has_boot_aff: Bool = !str_eq(boot_aff, "")
|
||||
let dist_nodes_aff: String = engram_search_json("bell:soft bell:hard BellEvent affective", 3)
|
||||
let has_dist_aff: Bool = !str_eq(dist_nodes_aff, "") && !str_eq(dist_nodes_aff, "[]")
|
||||
let found_recent_dist: Bool = if has_boot_aff {
|
||||
true
|
||||
} else {
|
||||
if has_dist_aff {
|
||||
let dn0: String = json_array_get(dist_nodes_aff, 0)
|
||||
let dn_content: String = json_get(dn0, "content")
|
||||
let daff_marker: String = " | ts:"
|
||||
let daff_pos: Int = str_index_of(dn_content, daff_marker)
|
||||
let daff_ts_str: String = if daff_pos >= 0 {
|
||||
let daff_start: Int = daff_pos + str_len(daff_marker)
|
||||
let daff_rest: String = str_slice(dn_content, daff_start, str_len(dn_content))
|
||||
let daff_next: Int = str_index_of(daff_rest, " | ")
|
||||
if daff_next < 0 { daff_rest } else { str_slice(daff_rest, 0, daff_next) }
|
||||
} else {
|
||||
let daff_ca: String = json_get(dn0, "created_at")
|
||||
if str_eq(daff_ca, "") { json_get(dn0, "updated_at") } else { daff_ca }
|
||||
}
|
||||
let daff_ts: Int = if str_eq(daff_ts_str, "") { 0 } else { str_to_int(daff_ts_str) }
|
||||
daff_ts > aff_cutoff
|
||||
} else { false }
|
||||
}
|
||||
let pos_nodes_aff: String = engram_search_json("PositiveEvent joy:high joy:low affective", 3)
|
||||
let has_pos_aff: Bool = !str_eq(pos_nodes_aff, "") && !str_eq(pos_nodes_aff, "[]")
|
||||
let found_recent_pos: Bool = if has_pos_aff && !found_recent_dist {
|
||||
let pn0: String = json_array_get(pos_nodes_aff, 0)
|
||||
let pn_content: String = json_get(pn0, "content")
|
||||
let paff_marker: String = " | ts:"
|
||||
let paff_pos: Int = str_index_of(pn_content, paff_marker)
|
||||
let paff_ts_str: String = if paff_pos >= 0 {
|
||||
let paff_start: Int = paff_pos + str_len(paff_marker)
|
||||
let paff_rest: String = str_slice(pn_content, paff_start, str_len(pn_content))
|
||||
let paff_next: Int = str_index_of(paff_rest, " | ")
|
||||
if paff_next < 0 { paff_rest } else { str_slice(paff_rest, 0, paff_next) }
|
||||
} else {
|
||||
let paff_ca: String = json_get(pn0, "created_at")
|
||||
if str_eq(paff_ca, "") { json_get(pn0, "updated_at") } else { paff_ca }
|
||||
}
|
||||
let paff_ts: Int = if str_eq(paff_ts_str, "") { 0 } else { str_to_int(paff_ts_str) }
|
||||
paff_ts > aff_cutoff
|
||||
} else { false }
|
||||
if found_recent_dist {
|
||||
"[RECENT CONTEXT: User recently expressed significant distress. Monitor for indirect crisis signals and respond with care.]\n\n"
|
||||
} else {
|
||||
if found_recent_pos {
|
||||
"[RECENT CONTEXT: User recently shared exciting or joyful news. Acknowledge and celebrate with them when relevant.]\n\n"
|
||||
} else { "" }
|
||||
}
|
||||
}
|
||||
let affective_prefix: String = affective_context_prefix()
|
||||
|
||||
let ctx: String = engram_compile(activation_seed)
|
||||
// Tell the LLM which engine it is running on this turn, so it can answer truthfully instead of
|
||||
@@ -1026,7 +1033,7 @@ fn handle_chat(body: String) -> String {
|
||||
// nodes stored under names like "Prism" unless those exact words appear in content.
|
||||
let session_preload: String = if hist_len == 0 {
|
||||
let profile_nodes: String = engram_search_json("user profile identity preferences", 5)
|
||||
let work_nodes: String = engram_search_json("in_progress active project work", 5)
|
||||
let work_nodes_0: String = engram_search_json("in_progress active project work", 5)
|
||||
let project_nodes: String = engram_search_json("project status current ongoing active", 5)
|
||||
let summary_nodes: String = engram_search_json("SessionSummary session:summary previous-session recent", 3)
|
||||
|
||||
@@ -1035,80 +1042,80 @@ fn handle_chat(body: String) -> String {
|
||||
// Issue 1: typed work query — WorkItem with in_progress label first.
|
||||
let work_nodes_typed: String = engram_search_json("WorkItem status:in_progress active work", 6)
|
||||
let work_ok_typed: Bool = !str_eq(work_nodes_typed, "") && !str_eq(work_nodes_typed, "[]")
|
||||
let work_nodes: String = if work_ok_typed {
|
||||
let work_nodes_1: String = if work_ok_typed {
|
||||
work_nodes_typed
|
||||
} else {
|
||||
engram_search_json("active project task current in_progress", 6)
|
||||
}
|
||||
let work_ok: Bool = !str_eq(work_nodes, "") && !str_eq(work_nodes, "[]")
|
||||
let work_ok: Bool = !str_eq(work_nodes_1, "") && !str_eq(work_nodes_1, "[]")
|
||||
let project_ok: Bool = !str_eq(project_nodes, "") && !str_eq(project_nodes, "[]")
|
||||
let summary_ok: Bool = !str_eq(summary_nodes, "") && !str_eq(summary_nodes, "[]")
|
||||
|
||||
let profile_bullets: String = if profile_ok {
|
||||
let pn: Int = json_array_len(profile_nodes)
|
||||
let bullets: String = ""
|
||||
let bullets = if pn > 0 {
|
||||
let bullets_0: String = ""
|
||||
let bullets_1 = if pn > 0 {
|
||||
let n0: String = json_array_get(profile_nodes, 0)
|
||||
let id0: String = json_get(n0, "id")
|
||||
let c0: String = json_get(n0, "content")
|
||||
let s0: String = if str_len(c0) > 120 { str_slice(c0, 0, 120) } else { c0 }
|
||||
if id_in_seen(id0, seen_ids) || str_eq(s0, "") { bullets } else { "- " + s0 }
|
||||
} else { bullets }
|
||||
let bullets = if pn > 1 {
|
||||
if id_in_seen(id0, seen_ids) || str_eq(s0, "") { bullets_0 } else { "- " + s0 }
|
||||
} else { bullets_0 }
|
||||
let bullets_2 = if pn > 1 {
|
||||
let n1: String = json_array_get(profile_nodes, 1)
|
||||
let id1: String = json_get(n1, "id")
|
||||
let c1: String = json_get(n1, "content")
|
||||
let s1: String = if str_len(c1) > 120 { str_slice(c1, 0, 120) } else { c1 }
|
||||
if id_in_seen(id1, seen_ids) || str_eq(s1, "") { bullets } else { bullets + "\n- " + s1 }
|
||||
} else { bullets }
|
||||
let bullets = if pn > 2 {
|
||||
if id_in_seen(id1, seen_ids) || str_eq(s1, "") { bullets_1 } else { bullets_1 + "\n- " + s1 }
|
||||
} else { bullets_1 }
|
||||
let bullets_3 = if pn > 2 {
|
||||
let n2: String = json_array_get(profile_nodes, 2)
|
||||
let id2: String = json_get(n2, "id")
|
||||
let c2: String = json_get(n2, "content")
|
||||
let s2: String = if str_len(c2) > 120 { str_slice(c2, 0, 120) } else { c2 }
|
||||
if id_in_seen(id2, seen_ids) || str_eq(s2, "") { bullets } else { bullets + "\n- " + s2 }
|
||||
} else { bullets }
|
||||
bullets
|
||||
if id_in_seen(id2, seen_ids) || str_eq(s2, "") { bullets_2 } else { bullets_2 + "\n- " + s2 }
|
||||
} else { bullets_2 }
|
||||
bullets_3
|
||||
} else { "" }
|
||||
|
||||
let work_bullets: String = if work_ok {
|
||||
let wn: Int = json_array_len(work_nodes)
|
||||
let wb: String = ""
|
||||
let wb = if wn > 0 {
|
||||
let w0: String = json_array_get(work_nodes, 0)
|
||||
let wn: Int = json_array_len(work_nodes_1)
|
||||
let wb_0: String = ""
|
||||
let wb_1 = if wn > 0 {
|
||||
let w0: String = json_array_get(work_nodes_1, 0)
|
||||
let wid0: String = json_get(w0, "id")
|
||||
let wc0: String = json_get(w0, "content")
|
||||
let ws0: String = if str_len(wc0) > 120 { str_slice(wc0, 0, 120) } else { wc0 }
|
||||
if id_in_seen(wid0, seen_ids) || str_eq(ws0, "") { wb } else { "- " + ws0 }
|
||||
} else { wb }
|
||||
let wb = if wn > 1 {
|
||||
let w1: String = json_array_get(work_nodes, 1)
|
||||
if id_in_seen(wid0, seen_ids) || str_eq(ws0, "") { wb_0 } else { "- " + ws0 }
|
||||
} else { wb_0 }
|
||||
let wb_2 = if wn > 1 {
|
||||
let w1: String = json_array_get(work_nodes_1, 1)
|
||||
let wid1: String = json_get(w1, "id")
|
||||
let wc1: String = json_get(w1, "content")
|
||||
let ws1: String = if str_len(wc1) > 120 { str_slice(wc1, 0, 120) } else { wc1 }
|
||||
if id_in_seen(wid1, seen_ids) || str_eq(ws1, "") { wb } else { wb + "\n- " + ws1 }
|
||||
} else { wb }
|
||||
wb
|
||||
if id_in_seen(wid1, seen_ids) || str_eq(ws1, "") { wb_1 } else { wb_1 + "\n- " + ws1 }
|
||||
} else { wb_1 }
|
||||
wb_2
|
||||
} else { "" }
|
||||
|
||||
let project_bullets: String = if project_ok {
|
||||
let prn: Int = json_array_len(project_nodes)
|
||||
let pb: String = ""
|
||||
let pb = if prn > 0 {
|
||||
let pb_0: String = ""
|
||||
let pb_1 = if prn > 0 {
|
||||
let pr0: String = json_array_get(project_nodes, 0)
|
||||
let prid0: String = json_get(pr0, "id")
|
||||
let prc0: String = json_get(pr0, "content")
|
||||
let ps0: String = if str_len(prc0) > 120 { str_slice(prc0, 0, 120) } else { prc0 }
|
||||
if id_in_seen(prid0, seen_ids) || str_eq(ps0, "") { pb } else { "- " + ps0 }
|
||||
} else { pb }
|
||||
let pb = if prn > 1 {
|
||||
if id_in_seen(prid0, seen_ids) || str_eq(ps0, "") { pb_0 } else { "- " + ps0 }
|
||||
} else { pb_0 }
|
||||
let pb_2 = if prn > 1 {
|
||||
let pr1: String = json_array_get(project_nodes, 1)
|
||||
let prid1: String = json_get(pr1, "id")
|
||||
let prc1: String = json_get(pr1, "content")
|
||||
let ps1: String = if str_len(prc1) > 120 { str_slice(prc1, 0, 120) } else { prc1 }
|
||||
if id_in_seen(prid1, seen_ids) || str_eq(ps1, "") { pb } else { pb + "\n- " + ps1 }
|
||||
} else { pb }
|
||||
pb
|
||||
if id_in_seen(prid1, seen_ids) || str_eq(ps1, "") { pb_1 } else { pb_1 + "\n- " + ps1 }
|
||||
} else { pb_1 }
|
||||
pb_2
|
||||
} else { "" }
|
||||
|
||||
let summary_bullet: String = if summary_ok {
|
||||
@@ -1528,6 +1535,134 @@ fn resolve_in_root(path: String, root: String) -> String {
|
||||
return root + "/" + path
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BUG-8: server-side risk tiers + a real fence for run_command.
|
||||
//
|
||||
// Before this block, the ONLY thing deciding whether a tool call paused for
|
||||
// user consent was is_builtin_tool() — a destructive shell command and a
|
||||
// read-only file read were treated identically (both auto-ran), and the
|
||||
// client's approval UI was the sole line of defense. Enforcement now lives
|
||||
// where the tools execute:
|
||||
//
|
||||
// "read" observes only — runs silently.
|
||||
// "reversible" workspace-confined writes with a client undo path — runs,
|
||||
// lands on the run receipt.
|
||||
// "escalate" irreversible / outward / shell — NEVER auto-runs. The loop
|
||||
// suspends to the client's consent flow; the /approve
|
||||
// round-trip IS the approval token, because the engine only
|
||||
// executes an escalated tool inside handle_session_approve.
|
||||
//
|
||||
// "Always allow" can never bypass the escalate tier (irreversible actions
|
||||
// always confirm — the value line). Unknown tools default to escalate.
|
||||
// The run_command fence refuses parent traversal, ~, command substitution,
|
||||
// and absolute paths outside the workspace — refusal, not a cwd suggestion.
|
||||
// Still lexical underneath (symlinks; see the LIMITATION note above): tiered
|
||||
// consent + the fence raise the floor a second and third rung; OS-level
|
||||
// confinement in el_runtime.c remains the ceiling, flagged for Will.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Read-only shell commands may auto-run (still fenced); anything with shell
|
||||
// plumbing (pipes, redirects, chaining) or an unknown head word escalates.
|
||||
fn run_command_is_readonly(cmd: String) -> Bool {
|
||||
if str_contains(cmd, "|") || str_contains(cmd, ">") || str_contains(cmd, "<") {
|
||||
return false
|
||||
}
|
||||
if str_contains(cmd, ";") || str_contains(cmd, "&") {
|
||||
return false
|
||||
}
|
||||
let sp: Int = str_index_of(cmd, " ")
|
||||
let first: String = if sp < 0 { cmd } else { str_slice(cmd, 0, sp) }
|
||||
if str_eq(first, "ls") || str_eq(first, "cat") || str_eq(first, "head") || str_eq(first, "tail") {
|
||||
return true
|
||||
}
|
||||
if str_eq(first, "grep") || str_eq(first, "wc") || str_eq(first, "find") || str_eq(first, "pwd") {
|
||||
return true
|
||||
}
|
||||
if str_eq(first, "echo") || str_eq(first, "date") || str_eq(first, "which") || str_eq(first, "file") || str_eq(first, "stat") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// True if the command references an absolute path (introduced by `needle`,
|
||||
// whose last char is the "/") that does NOT stay inside the workspace root.
|
||||
fn cmd_abs_escape_at(cmd: String, root: String, needle: String) -> Bool {
|
||||
let rest: String = cmd
|
||||
let found: Bool = false
|
||||
while !found && str_contains(rest, needle) {
|
||||
let idx: Int = str_index_of(rest, needle)
|
||||
let slash_at: Int = idx + str_len(needle) - 1
|
||||
let after: String = str_slice(rest, slash_at, str_len(rest))
|
||||
let ok: Bool = str_starts_with(after, root + "/") || str_starts_with(after, root + " ") || str_eq(after, root)
|
||||
let found = if !ok { true } else { found }
|
||||
let rest = str_slice(rest, slash_at + 1, str_len(rest))
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// The run_command fence. Returns "" when the command may run, else the denial
|
||||
// message (sent back to the model as the tool result, same pattern as the
|
||||
// path tools). Root is REQUIRED for shell: no workspace, no commands.
|
||||
fn run_command_guard(cmd: String, root: String) -> String {
|
||||
if str_eq(root, "") {
|
||||
return "denied: no workspace folder is set — the user must choose a workspace folder in the Agent panel before shell commands can run"
|
||||
}
|
||||
if str_contains(cmd, "..") {
|
||||
return "denied: parent-directory traversal ('..') is not allowed"
|
||||
}
|
||||
if str_contains(cmd, "~") {
|
||||
return "denied: home-directory references ('~') are not allowed"
|
||||
}
|
||||
if str_contains(cmd, "$(") || str_contains(cmd, "`") {
|
||||
return "denied: command substitution is not allowed"
|
||||
}
|
||||
if str_starts_with(cmd, "/") && !str_starts_with(cmd, root + "/") {
|
||||
return "denied: absolute paths outside the workspace are not allowed"
|
||||
}
|
||||
if cmd_abs_escape_at(cmd, root, " /") || cmd_abs_escape_at(cmd, root, "\"/") || cmd_abs_escape_at(cmd, root, "'/") {
|
||||
return "denied: absolute paths outside the workspace are not allowed"
|
||||
}
|
||||
if cmd_abs_escape_at(cmd, root, "=/") || cmd_abs_escape_at(cmd, root, ">/") || cmd_abs_escape_at(cmd, root, "</") || cmd_abs_escape_at(cmd, root, "(/") {
|
||||
return "denied: absolute paths outside the workspace are not allowed"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// The engine's own risk classification for a tool call. Client UI renders it;
|
||||
// the engine ENFORCES it.
|
||||
fn classify_tool_risk(tool_name: String, tool_input: String) -> String {
|
||||
if str_eq(tool_name, "read_file") || str_eq(tool_name, "list_files") || str_eq(tool_name, "grep") {
|
||||
return "read"
|
||||
}
|
||||
if str_eq(tool_name, "search_memory") || str_eq(tool_name, "recall") || str_eq(tool_name, "web_get") {
|
||||
return "read"
|
||||
}
|
||||
if str_eq(tool_name, "remember") || str_eq(tool_name, "neuron_remember") {
|
||||
return "reversible"
|
||||
}
|
||||
if str_starts_with(tool_name, "neuron_") {
|
||||
return "read"
|
||||
}
|
||||
if str_eq(tool_name, "write_file") || str_eq(tool_name, "edit_file") {
|
||||
let root: String = agent_workspace_root()
|
||||
// Unscoped writes (no workspace chosen) are not "reversible" — escalate.
|
||||
if str_eq(root, "") {
|
||||
return "escalate"
|
||||
}
|
||||
return "reversible"
|
||||
}
|
||||
if str_eq(tool_name, "run_command") {
|
||||
let cmd: String = json_get(tool_input, "command")
|
||||
let root: String = agent_workspace_root()
|
||||
if !str_eq(root, "") && run_command_is_readonly(cmd) {
|
||||
return "read"
|
||||
}
|
||||
return "escalate"
|
||||
}
|
||||
// Unknown tool = escalate. Default-deny, never default-allow.
|
||||
return "escalate"
|
||||
}
|
||||
|
||||
fn dispatch_tool(tool_name: String, tool_input: String) -> String {
|
||||
if str_eq(tool_name, "read_file") {
|
||||
let path: String = json_get(tool_input, "path")
|
||||
@@ -1550,6 +1685,10 @@ fn dispatch_tool(tool_name: String, tool_input: String) -> String {
|
||||
}
|
||||
if str_eq(tool_name, "web_get") {
|
||||
let url: String = json_get(tool_input, "url")
|
||||
// BUG-8: scheme guard — web_get had no guard at all (file:// etc).
|
||||
if !str_starts_with(url, "http://") && !str_starts_with(url, "https://") {
|
||||
return json_safe("denied: only http(s) URLs can be fetched")
|
||||
}
|
||||
let result: String = http_get(url)
|
||||
return json_safe(result)
|
||||
}
|
||||
@@ -1561,7 +1700,14 @@ fn dispatch_tool(tool_name: String, tool_input: String) -> String {
|
||||
if str_eq(tool_name, "run_command") {
|
||||
let cmd: String = json_get(tool_input, "command")
|
||||
let root: String = agent_workspace_root()
|
||||
let scoped: String = if str_eq(root, "") { cmd } else { "cd " + root + " && ( " + cmd + " )" }
|
||||
// BUG-8(B): the fence — refusal, not a cwd suggestion. Applies on EVERY
|
||||
// execution path (auto-run in the loop AND post-consent dispatch from
|
||||
// handle_session_approve), because both land here.
|
||||
let denial: String = run_command_guard(cmd, root)
|
||||
if !str_eq(denial, "") {
|
||||
return json_safe(denial)
|
||||
}
|
||||
let scoped: String = "cd " + root + " && ( " + cmd + " )"
|
||||
let result: String = exec_capture(scoped)
|
||||
return json_safe(result)
|
||||
}
|
||||
@@ -1991,6 +2137,19 @@ fn agentic_loop(session_id: String, model: String, safe_sys: String, tools_json:
|
||||
let pend_tool_id: String = ""
|
||||
let pend_tool_name: String = ""
|
||||
let pend_tool_input: String = ""
|
||||
let pend_tool_tier: String = ""
|
||||
let pend_narration: String = ""
|
||||
|
||||
// Live run-progress ledger (2026-07-13, proposed with the narrated-runs work):
|
||||
// the model already narrates its intent in a text block before every tool call,
|
||||
// and the loop previously DISCARDED that prose on tool rounds. Each iteration now
|
||||
// appends {"i":N,"t":"<narration>","tool":"<name>"} to state key
|
||||
// run_progress_<session_id>; the client polls GET /api/run-progress/<session_id>
|
||||
// during a run to render live step updates (the Cowork pattern) without needing
|
||||
// streaming. Reset at loop start; a {"done":true} entry lands on completion.
|
||||
if !str_eq(session_id, "") {
|
||||
state_set("run_progress_" + session_id, "")
|
||||
}
|
||||
|
||||
while keep_going && iteration < 8 {
|
||||
let req_body: String = "{\"model\":\"" + model + "\""
|
||||
@@ -2047,7 +2206,13 @@ fn agentic_loop(session_id: String, model: String, safe_sys: String, tools_json:
|
||||
let always_key: String = "always_allow_" + session_id
|
||||
let always_list: String = if !str_eq(session_id, "") { state_get(always_key) } else { "" }
|
||||
let is_always_allowed: Bool = !str_eq(tool_name, "") && !str_eq(always_list, "") && str_contains(always_list, tool_name)
|
||||
let needs_bridge: Bool = is_tool_turn && !is_builtin_tool(tool_name) && !is_always_allowed
|
||||
// BUG-8(A): the engine classifies every tool call and REFUSES to auto-run
|
||||
// the escalate tier — being a builtin is no longer a free pass, and
|
||||
// "always allow" can never bypass escalate (irreversible actions always
|
||||
// confirm). Escalated calls suspend to the client's consent flow; the
|
||||
// /approve round-trip is the only path that executes them.
|
||||
let risk_tier: String = if is_tool_turn { classify_tool_risk(tool_name, tool_input) } else { "" }
|
||||
let needs_bridge: Bool = is_tool_turn && (str_eq(risk_tier, "escalate") || (!is_builtin_tool(tool_name) && !is_always_allowed))
|
||||
|
||||
// Built-in tools dispatch locally; bridged tools yield "" (never sent upstream).
|
||||
let tool_result_raw: String = if is_tool_turn && !needs_bridge { dispatch_tool(tool_name, tool_input) } else { "" }
|
||||
@@ -2078,11 +2243,27 @@ fn agentic_loop(session_id: String, model: String, safe_sys: String, tools_json:
|
||||
"[" + inner2 + ",{\"role\":\"user\",\"content\":[" + tool_msg + "]}]"
|
||||
} else { messages }
|
||||
|
||||
// Live progress ledger: one entry per round — the model's own narration
|
||||
// (its pre-tool prose, previously discarded here) plus the tool it reached
|
||||
// for. Clients poll /api/run-progress/<sid> to render these live.
|
||||
if !str_eq(session_id, "") {
|
||||
let prog_key: String = "run_progress_" + session_id
|
||||
let prog_prev: String = state_get(prog_key)
|
||||
let prog_snip: String = if str_len(text_out) > 280 { str_slice(text_out, 0, 280) } else { text_out }
|
||||
let prog_entry: String = "{\"i\":" + int_to_str(iteration)
|
||||
+ ",\"t\":\"" + json_safe(prog_snip) + "\""
|
||||
+ ",\"tool\":\"" + json_safe(tool_name) + "\"}"
|
||||
let prog_next: String = if str_eq(prog_prev, "") { prog_entry } else { prog_prev + "," + prog_entry }
|
||||
state_set(prog_key, prog_next)
|
||||
}
|
||||
|
||||
// Bridge turn: persist the continuation and stop the loop.
|
||||
let pending = if needs_bridge { true } else { pending }
|
||||
let pend_tool_id = if needs_bridge { tool_id } else { pend_tool_id }
|
||||
let pend_tool_name = if needs_bridge { tool_name } else { pend_tool_name }
|
||||
let pend_tool_input = if needs_bridge { tool_input } else { pend_tool_input }
|
||||
let pend_tool_tier = if needs_bridge { risk_tier } else { pend_tool_tier }
|
||||
let pend_narration = if needs_bridge { text_out } else { pend_narration }
|
||||
// Stash messages-with-the-assistant-request so resume only needs to append the
|
||||
// client's tool_result block. messages_with_assistant is only meaningful when a
|
||||
// tool was requested, so guard on needs_bridge before persisting.
|
||||
@@ -2103,6 +2284,8 @@ fn agentic_loop(session_id: String, model: String, safe_sys: String, tools_json:
|
||||
+ ",\"call_id\":\"" + pend_tool_id + "\""
|
||||
+ ",\"tool_name\":\"" + pend_tool_name + "\""
|
||||
+ ",\"tool_input\":" + safe_in
|
||||
+ ",\"risk_tier\":\"" + pend_tool_tier + "\""
|
||||
+ ",\"narration\":\"" + json_safe(pend_narration) + "\""
|
||||
+ ",\"model\":\"" + model + "\""
|
||||
+ ",\"agentic\":true"
|
||||
+ ",\"tools_used\":" + tools_arr + "}"
|
||||
@@ -2124,6 +2307,13 @@ fn agentic_loop(session_id: String, model: String, safe_sys: String, tools_json:
|
||||
|
||||
let safe_text: String = json_safe(final_text)
|
||||
let tools_arr: String = if str_eq(tools_log, "") { "[]" } else { "[" + tools_log + "]" }
|
||||
// Close the live-progress ledger: pollers see {"done":true} and stop.
|
||||
if !str_eq(session_id, "") {
|
||||
let done_key: String = "run_progress_" + session_id
|
||||
let done_prev: String = state_get(done_key)
|
||||
let done_next: String = if str_eq(done_prev, "") { "{\"done\":true}" } else { done_prev + ",{\"done\":true}" }
|
||||
state_set(done_key, done_next)
|
||||
}
|
||||
return "{\"reply\":\"" + safe_text + "\",\"model\":\"" + model + "\",\"agentic\":true,\"tools_used\":" + tools_arr + ",\"iterations\":" + int_to_str(iteration) + "}"
|
||||
}
|
||||
|
||||
|
||||
@@ -491,6 +491,18 @@ fn handle_request(method: String, path: String, body: String) -> String {
|
||||
if str_starts_with(clean, "/api/connectors") {
|
||||
return handle_connectors(method, clean, body)
|
||||
}
|
||||
// GET /api/run-progress/:session_id — live agentic-run ledger (2026-07-13,
|
||||
// narrated-runs). agentic_loop appends one {"i","t","tool"} entry per round
|
||||
// (the model's own pre-tool narration); a {"done":true} entry closes the run.
|
||||
// Clients poll this during a run to render live step updates without streaming.
|
||||
if str_starts_with(clean, "/api/run-progress/") {
|
||||
let rp_id: String = str_slice(clean, 18, str_len(clean))
|
||||
if !str_eq(rp_id, "") {
|
||||
let rp_raw: String = state_get("run_progress_" + rp_id)
|
||||
let rp_arr: String = if str_eq(rp_raw, "") { "[]" } else { "[" + rp_raw + "]" }
|
||||
return "{\"progress\":" + rp_arr + "}"
|
||||
}
|
||||
}
|
||||
// GET /api/sessions — list all sessions
|
||||
if str_eq(clean, "/api/sessions") {
|
||||
return session_list()
|
||||
|
||||
@@ -46,7 +46,9 @@ fn handle_config(method: String, body: String) -> String {
|
||||
}
|
||||
}
|
||||
let current_model: String = state_get("soul_model")
|
||||
let display: String = if str_eq(current_model, "") { "claude-sonnet-4-5" } else { current_model }
|
||||
// Display fallback aligned with the intended product default (was claude-sonnet-4-5,
|
||||
// which silently became the app's picker default on fresh profiles — 2026-07-13).
|
||||
let display: String = if str_eq(current_model, "") { "claude-opus-4-8" } else { current_model }
|
||||
return "{\"model\":\"" + display + "\",\"ok\":true}"
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user