feat: structured __renderer envelopes for MCP tools
All 19 oikos tools now return JSON envelopes with __renderer hints
(terminal, service_status, ping, path_report, etc.) carrying structured
data alongside the model-facing prose in a `message` field — agent
behavior is unchanged while the UI renders native cards.
Fixes: ping_service SQL (json||text precedence), timestamptz scan,
run:{...} action prefix parsing, get_execution_status target slug join.
Also: deploy.sh step 5.5 restarts dsh web after API deploy; watchdog
LaunchAgent detects API recovery and kickstarts dsh web for stale MCP
sessions.
This commit is contained in:
@@ -450,12 +450,86 @@ func annotateJSONResult(result *mcp.CallToolResult, rendererID string) *mcp.Call
|
||||
if err := json.Unmarshal([]byte(tc.Text), &items); err != nil {
|
||||
return result
|
||||
}
|
||||
wrapper := map[string]any{
|
||||
return rendererEnvelope(rendererID, items)
|
||||
}
|
||||
|
||||
// rendererEnvelope wraps any JSON-serializable payload in the __renderer
|
||||
// envelope oikos-ui dispatches on. data may be a row list (array) or a single
|
||||
// structured object; callers that only have prose keep textResult.
|
||||
func rendererEnvelope(rendererID string, data any) *mcp.CallToolResult {
|
||||
payload, err := json.MarshalIndent(map[string]any{
|
||||
"__renderer": rendererID,
|
||||
"data": items,
|
||||
"data": data,
|
||||
}, "", " ")
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("%v", data))
|
||||
}
|
||||
data, _ := json.MarshalIndent(wrapper, "", " ")
|
||||
return textResult(string(data))
|
||||
return textResult(string(payload))
|
||||
}
|
||||
|
||||
// terminalEnvelope is the structured shape of a command-execution result:
|
||||
// everything a terminal card needs (target, command, combined output, state)
|
||||
// plus the model-facing prose in Message, so restructuring the result for the
|
||||
// UI never changes what the agent reads.
|
||||
type terminalEnvelope struct {
|
||||
Target string `json:"target"`
|
||||
Command string `json:"command"`
|
||||
State string `json:"state"` // queued | started | done | error | refused
|
||||
Risk string `json:"risk,omitempty"`
|
||||
ExecID string `json:"exec_id,omitempty"`
|
||||
Output string `json:"output,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
NeedsApproval bool `json:"needs_approval,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// executionTerminalEnvelope builds the terminal envelope for a stored
|
||||
// execution row: command/purpose from the action JSON, output/error from the
|
||||
// result JSON, state from the execution status.
|
||||
func executionTerminalEnvelope(action, riskClass, status, result string) terminalEnvelope {
|
||||
env := terminalEnvelope{
|
||||
Command: action,
|
||||
Risk: riskClass,
|
||||
State: "started",
|
||||
Message: "",
|
||||
}
|
||||
var act struct {
|
||||
Command string `json:"command"`
|
||||
Purpose string `json:"purpose"`
|
||||
}
|
||||
// The stored action prefixes the JSON payload with the tool kind
|
||||
// (`run:{...}`); parse from the first '{'.
|
||||
if i := strings.Index(action, "{"); i >= 0 {
|
||||
if err := json.Unmarshal([]byte(action[i:]), &act); err == nil && act.Command != "" {
|
||||
env.Command = act.Command
|
||||
if act.Purpose != "" {
|
||||
env.Message = "Purpose: " + act.Purpose + "\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
switch status {
|
||||
case "completed":
|
||||
env.State = "done"
|
||||
case "failed":
|
||||
env.State = "error"
|
||||
case "cancelled":
|
||||
env.State = "refused"
|
||||
}
|
||||
var res struct {
|
||||
Output string `json:"output"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(result), &res); err == nil {
|
||||
env.Output = res.Output
|
||||
env.Error = res.Error
|
||||
} else if result != "" {
|
||||
env.Output = result
|
||||
}
|
||||
env.Message += fmt.Sprintf("Execution %s: %s.", status, status)
|
||||
if env.Error != "" {
|
||||
env.Message += " Error: " + env.Error
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// ─── SSH helpers ─────────────────────────────────────────────────────────
|
||||
@@ -650,24 +724,41 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, execSvc *app.ExecutionS
|
||||
return db.NewExecutionLog(ctx, pool, uuid.MustParse(string(execID)), correlationID)
|
||||
},
|
||||
})
|
||||
return renderSubmit(targetSlug, command, res)
|
||||
return renderSubmit(targetSlug, command, purpose, res)
|
||||
}
|
||||
|
||||
// renderSubmit maps an ExecutionSubmitResult onto the agent-facing text,
|
||||
// preserving the exact pre-service message shapes.
|
||||
func renderSubmit(targetSlug, command string, res app.ExecutionSubmitResult) *mcp.CallToolResult {
|
||||
// renderSubmit maps an ExecutionSubmitResult onto the agent-facing result: a
|
||||
// terminal envelope whose Message keeps the exact pre-service prose (the
|
||||
// agent's operating instructions live there), with the structured fields the
|
||||
// terminal card renders.
|
||||
func renderSubmit(targetSlug, command, purpose string, res app.ExecutionSubmitResult) *mcp.CallToolResult {
|
||||
d := res.Decision
|
||||
env := terminalEnvelope{
|
||||
Target: targetSlug,
|
||||
Command: command,
|
||||
State: "unknown",
|
||||
Message: "",
|
||||
}
|
||||
if purpose != "" {
|
||||
env.Message = "Purpose: " + purpose + "\n"
|
||||
}
|
||||
switch d.Action {
|
||||
case app.DecisionRefuse:
|
||||
return textResult(d.Message)
|
||||
env.State = "refused"
|
||||
env.Error = d.Message
|
||||
env.Message += d.Message
|
||||
|
||||
case app.DecisionQueue:
|
||||
confirmNote := ""
|
||||
if d.RiskClass == policy.RiskDestructive {
|
||||
confirmNote = " This is classified DESTRUCTIVE — flag that clearly to the operator; it needs explicit confirmation, not just a casual \"go ahead\"."
|
||||
}
|
||||
return textResult(fmt.Sprintf("run on %s requires approval (risk: %s) — execution %s queued.%s Present the command and purpose to the operator and wait; do not re-request.",
|
||||
targetSlug, d.RiskClass, res.ExecutionID, confirmNote))
|
||||
env.State = "queued"
|
||||
env.Risk = string(d.RiskClass)
|
||||
env.ExecID = string(res.ExecutionID)
|
||||
env.NeedsApproval = true
|
||||
env.Message += fmt.Sprintf("run on %s requires approval (risk: %s) — execution %s queued.%s Present the command and purpose to the operator and wait; do not re-request.",
|
||||
targetSlug, d.RiskClass, res.ExecutionID, confirmNote)
|
||||
|
||||
case app.DecisionAuto:
|
||||
label := d.RiskClass
|
||||
@@ -677,6 +768,8 @@ func renderSubmit(targetSlug, command string, res app.ExecutionSubmitResult) *mc
|
||||
case "destructive":
|
||||
label = "destructive"
|
||||
}
|
||||
env.Risk = string(label)
|
||||
env.ExecID = string(res.ExecutionID)
|
||||
if res.AsyncStarted {
|
||||
via := ""
|
||||
switch d.AutoViaWindow {
|
||||
@@ -687,11 +780,17 @@ func renderSubmit(targetSlug, command string, res app.ExecutionSubmitResult) *mc
|
||||
default:
|
||||
via = ", async"
|
||||
}
|
||||
return textResult(fmt.Sprintf("run on %s (%s%s): started — execution %s. Poll with get_execution_status(%s) for result.",
|
||||
targetSlug, label, via, res.ExecutionID, res.ExecutionID))
|
||||
env.State = "started"
|
||||
env.Message += fmt.Sprintf("run on %s (%s%s): started — execution %s. Poll with get_execution_status(%s) for result.",
|
||||
targetSlug, label, via, res.ExecutionID, res.ExecutionID)
|
||||
break
|
||||
}
|
||||
if res.Err != nil {
|
||||
return textResult(fmt.Sprintf("run on %s: ERROR %v\n%s", targetSlug, res.Err, res.Output))
|
||||
env.State = "error"
|
||||
env.Error = res.Err.Error()
|
||||
env.Output = res.Output
|
||||
env.Message += fmt.Sprintf("run on %s: ERROR %v\n%s", targetSlug, res.Err, res.Output)
|
||||
break
|
||||
}
|
||||
via := ", auto"
|
||||
switch d.AutoViaWindow {
|
||||
@@ -700,9 +799,14 @@ func renderSubmit(targetSlug, command string, res app.ExecutionSubmitResult) *mc
|
||||
case "destructive":
|
||||
via = ", auto via confirmed-target window"
|
||||
}
|
||||
return textResult(fmt.Sprintf("run on %s (%s%s): %s", targetSlug, label, via, res.Output))
|
||||
env.State = "done"
|
||||
env.Output = res.Output
|
||||
env.Message += fmt.Sprintf("run on %s (%s%s): %s", targetSlug, label, via, res.Output)
|
||||
|
||||
default:
|
||||
env.Message += fmt.Sprintf("run on %s: unknown decision %q", targetSlug, d.Action)
|
||||
}
|
||||
return textResult(fmt.Sprintf("run on %s: unknown decision %q", targetSlug, d.Action))
|
||||
return rendererEnvelope("terminal", env)
|
||||
}
|
||||
|
||||
// autoApprove updates the approval + execution status in the DB to approved,
|
||||
|
||||
Reference in New Issue
Block a user