Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/tasks-and-automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ Task IDs and records are actor/workspace/session scoped. The web task API expose

Simple work does not require a plan, and a plan is optional for medium work. Complex direct work uses a durable Working Plan with runtime-owned stable IDs and explicit acceptance criteria. The typed `update_plan` boundary applies revision-checked batches of add, update, cancel, reorder, dependency, split, merge, replace, block, and unblock operations; legacy `complexity`/`tasks` input is adapted through the same validation path. Splitting, merging, or replacing retains superseded tasks as cancelled history and records both directions of the relationship. Blocking requires a reason and prevents scheduling until explicitly unblocked with a reason. Each structural revision and its optional rationale are written atomically with a complete plan/task snapshot in the event history. A changed task must still be pending and unexecuted; executed task records and evidence are preserved. Scheduling follows plan order and waits for every dependency to succeed. A plan completes only when every task succeeds with acceptance evidence; recovery also reconciles a crash between the final task write and the plan status write. Clearing an active plan cancels its unfinished steps. The foreground scheduler runs consecutive calls concurrently only when every call is catalog-marked safe and read-only; all other calls preserve response order, and task receipts are reconciled in that order. Assistant answers remain available when a complex turn ends with unfinished work; its runtime outcome is reported separately as completed, incomplete, blocked, or cancelled. This outcome uses only that turn's Working Plan, so an unrelated task ledger cannot make a simple answer appear unfinished. The native foreground loop uses typed provider calls; the legacy text protocol remains available through its compatibility adapter.

The TUI and Web chat show the current Working Plan revision, its user-facing revision reason, and tasks in scheduler order. Progress is updated from typed task events; successful tasks are shown as verified, and cancelled replacements remain marked as superseded. The Web view restores the active plan snapshot when a saved session is reopened. These observer projections contain task descriptions, statuses, supersession links, and rationale only; execution evidence and tool arguments remain private.

The tool executor makes at most one automatic retry, and only when a typed error marks a normal-risk read or an allowlisted built-in network lookup as retriable. It rechecks task binding and child cancellation before dispatching the retry. Filesystem, process, Git, stateful, custom network, and other mutation-capable calls are never automatically retried. After interruption or restart, unfinished side effects remain uncertain; inspect the current workspace and recorded receipts before requesting the mutation again.

Plan Mode can produce a versioned Strategic Plan before execution. Acceptance records the exact objectives, acceptance criteria, workspace paths, capabilities, effect categories, and risk ceiling alongside its linked Working Plan. A step may also carry a visible, fingerprinted `execution` contract for one exact `write_file`, `edit_file`, or `apply_patch` call; delegated file mutations require this explicit contract and still pass assigned-file, workspace-bound, and tool-approval checks. `apply_patch` requires the hash from a structured read and ordered `{old_text, new_text}` hunks, each matching exactly once without overlap. Each criterion should include a `verification` action and exact object arguments. A `read_file` verifier must also include one case-sensitive `expect` predicate (`equals` or nonempty `contains`); the returned text must satisfy it before the criterion is recorded. Plain-text criteria remain readable for legacy proposals but cannot satisfy the per-criterion completion gate without a verifier. Runtime tool dispatch checks each tool's catalog policy and supplied paths against those accepted bounds; shell processes are denied because this runtime cannot confine arbitrary commands to those paths. An unbounded legacy proposal receives read-only defaults. Strategic acceptance does not grant capabilities, bypass tool approvals, or establish completion evidence.
Expand Down
17 changes: 16 additions & 1 deletion openkyrozen/agent/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,19 @@ def runtime_event_as_legacy(event: "RuntimeEvent") -> dict[str, Any] | None:
if event.kind == "tool.result":
return {"event": "tool_receipt", "tool_receipt": payload}
if event.kind == "task.snapshot":
return {"event": "tasks", "tasks": payload.get("tasks", [])}
return {"event": "tasks", "tasks": payload.get("tasks", []),
"working_plan": payload.get("working_plan")}
if event.kind.startswith("working_plan."):
return {"event": "working_plan", "session_id": event.session_id, "working_plan": payload}
Comment on lines +40 to +41

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Project only full Working Plan snapshots

Do not treat every working_plan.* payload as a renderable snapshot. TaskManager.adopt_legacy_plan still emits working_plan.migrated with only plan_id, complexity, and task_ids; during a complex legacy Plan/TaskList turn this branch forwards that metadata to both clients, which replace the current panel with a plan lacking revision, status, timestamps, and task objects until a later task snapshot arrives. Emit working_plan_snapshot() for that event or restrict this projection to event kinds whose payload has the full snapshot shape.

Useful? React with 👍 / 👎.

if event.kind.startswith("task.") and event.task_id:
name = event.kind.partition(".")[2]
status = {"created": "pending", "claimed": "running", "recovered": "pending",
"resumed": "pending"}.get(name, name)
if status not in {"pending", "running", "succeeded", "failed", "blocked", "cancelled"}:
status = None
return {"event": "task_update", "task_id": event.task_id,
"session_id": event.session_id, "status": status, "description": payload.get("description"),
"reason": payload.get("reason", "")}
if event.kind == "interaction.updated":
return {"event": "interaction", "interaction": payload.get("interaction", payload)}
if event.kind == "security.permission_check":
Expand Down Expand Up @@ -199,6 +211,9 @@ def publish_legacy(self, legacy_event: dict[str, Any], *, model_request_id: str
from .types import _bounded_redacted
payload = _bounded_redacted(dict(fields.get("tool_receipt") or {}), budget=12_000)
legacy_event = {"event": "tool_receipt", "tool_receipt": payload}
elif name == "tasks":
legacy_event = {"event": "tasks", "tasks": fields.get("tasks", []),
"working_plan": fields.get("working_plan")}
call_id = payload.get("call_id") if isinstance(payload.get("call_id"), str) else None
task_id = payload.get("task_id") if isinstance(payload.get("task_id"), str) else None
event = self.publish(kind_map.get(name, f"legacy.{name}"), payload,
Expand Down
2 changes: 1 addition & 1 deletion openkyrozen/agent/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -885,7 +885,7 @@ def _record_turn_receipt(self, receipt: ExecutionReceipt, *, allow_legacy_task_m
self._emit_stream_event({"event": "tasks", "tasks": [
{"id": item["id"], "description": item["description"], "status": item["status"]}
for item in self.tasks.tasks
]})
], "working_plan": self.tasks.working_plan_snapshot()})
return result


Expand Down
2 changes: 1 addition & 1 deletion openkyrozen/interfaces/tui/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ def _command(self, name: str, args: Any, request_id: str) -> None:
self.emit("tasks", request_id, tasks=[
{"id": task["id"], "description": task["description"], "status": task["status"]}
for task in runtime.tasks.tasks
])
], working_plan=runtime.tasks.working_plan_snapshot())
self.emit("response", request_id, text=runtime.tasks.format())
elif command in {"/agents", "agents"}:
coordinator = runtime.delegation()
Expand Down
2 changes: 1 addition & 1 deletion openkyrozen/interfaces/tui/navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ def _emit_bound_state(self, request_id: str) -> None:
self.emit("tasks", request_id, tasks=[
{"id": item["id"], "description": item["description"], "status": item["status"]}
for item in runtime.tasks.tasks
])
], working_plan=runtime.tasks.working_plan_snapshot())
self.interaction(request_id)
self.graph_state(request_id)
self.emit("ready", request_id, configured=runtime.llm_provider is not None,
Expand Down
2 changes: 1 addition & 1 deletion openkyrozen/interfaces/tui/onboarding.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ def start(self, payload: dict[str, Any], request_id: str) -> None:
self.emit("tasks", request_id, tasks=[
{"id": item["id"], "description": item["description"], "status": item["status"]}
for item in runtime.tasks.tasks
])
], working_plan=runtime.tasks.working_plan_snapshot())
self.emit(
"ready", request_id, configured=configured,
provider=getattr(config, "provider", ""),
Expand Down
12 changes: 10 additions & 2 deletions openkyrozen/interfaces/tui/turns.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,15 @@ def callback(event: dict[str, Any] | RuntimeEvent) -> None:
elif kind == "tool_receipt":
self.emit("tool_receipt", request_id, receipt=event.get("tool_receipt", {}))
elif kind == "tasks":
self.emit("tasks", request_id, tasks=event.get("tasks", []))
self.emit("tasks", request_id, tasks=event.get("tasks", []),
working_plan=event.get("working_plan"))
elif kind == "working_plan":
self.emit("working_plan", request_id, session_id=event.get("session_id"),
working_plan=event.get("working_plan"))
elif kind == "task_update":
self.emit("task_update", request_id, session_id=event.get("session_id"),
task_id=event.get("task_id"),
status=event.get("status"), reason=event.get("reason", ""))
elif kind == "interaction":
self.emit("interaction", request_id, interaction=event.get("interaction", {}))
elif kind == "permission_check":
Expand Down Expand Up @@ -310,7 +318,7 @@ def _run_submit(self, text: str, request_id: str, plan_acceptance: dict[str, Any
self.emit("tasks", request_id, tasks=[
{"id": task["id"], "description": task["description"], "status": task["status"]}
for task in runtime.tasks.tasks
])
], working_plan=runtime.tasks.working_plan_snapshot())
self.interaction(request_id)
self.usage(request_id)
self.status("ready", "Ready", request_id)
Expand Down
4 changes: 3 additions & 1 deletion openkyrozen/interfaces/web/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -555,7 +555,9 @@ async def generate():
elif kind == "tool_receipt":
yield f"data: {json.dumps({'event': 'tool_receipt', 'tool_receipt': event.get('tool_receipt')}, ensure_ascii=False)}\n\n"
elif kind == "tasks":
yield f"data: {json.dumps({'event': 'tasks', 'tasks': event.get('tasks', [])}, ensure_ascii=False)}\n\n"
yield f"data: {json.dumps({'event': 'tasks', 'tasks': event.get('tasks', []), 'working_plan': event.get('working_plan')}, ensure_ascii=False)}\n\n"
elif kind in {"working_plan", "task_update"}:
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
elif kind == "subagent":
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
elif kind == "runtime_event":
Expand Down
5 changes: 4 additions & 1 deletion openkyrozen/interfaces/web/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,5 +79,8 @@ async def api_v2_session(self, session_id: str):
session_id = service._normalise_session_id(session_id)
with service._chat_lock:
session = service._get_or_create_session(session_id, service._SERVER_ACTOR_ID)
bound = service._agent.open_session(session_id, user_id=service._SERVER_ACTOR_ID)
working_plan = bound.tasks.working_plan_snapshot()
return {"session_id": session_id, "messages": session["messages"], "updated": session.get("updated"),
"interaction": service._interaction_for_session(session).envelope(), "context": session.get("context")}
"interaction": service._interaction_for_session(session).envelope(), "context": session.get("context"),
"working_plan": working_plan}
48 changes: 48 additions & 0 deletions openkyrozen/interfaces/web/templates/chat.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
#chat:empty::before{content:'Start a conversation or type / for commands.';display:block;color:var(--muted);text-align:center;padding:12vh 0}
#interaction-card{width:min(100%,1024px);margin:14px auto 0;padding:14px 17px;background:var(--surface-hi);border:1px solid var(--brand);border-radius:10px}
#interaction-card[hidden]{display:none}#interaction-card h2{font-size:15px;color:var(--brand);margin-bottom:8px}#interaction-card fieldset{border:0;margin:12px 0}#interaction-card legend{font-weight:700;margin-bottom:6px}#interaction-card label{display:block;margin:6px 0;color:var(--text)}#interaction-card input[type=text]{width:100%;background:var(--ink);border:1px solid var(--line);border-radius:6px;color:var(--text);padding:8px;margin-top:5px}#interaction-card ol{padding-left:22px;margin:8px 0}#interaction-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}
#working-plan{width:min(100%,1024px);margin:14px auto 0;padding:14px 17px;background:var(--surface);border:1px solid var(--line);border-radius:10px}#working-plan[hidden]{display:none}#working-plan h2{font-size:15px;color:var(--brand)}#working-plan p{color:var(--muted);margin:5px 0}#working-plan ol{padding-left:24px;margin-top:8px}#working-plan li{padding:3px 0}#working-plan li.cancelled,#working-plan li.superseded{color:var(--muted);text-decoration:line-through}#working-plan .plan-status{font-size:12px;color:var(--muted)}
.msg{margin-bottom:22px;max-width:min(88%,860px)}
.msg.user{margin-left:auto}
.msg .role{font-size:11px;letter-spacing:.08em;color:var(--muted);margin-bottom:6px}
Expand Down Expand Up @@ -95,6 +96,7 @@ <h2 id="history-title">Conversation history</h2>
</div>
<div id="chat" aria-live="polite" aria-label="Conversation"></div>
<section id="interaction-card" aria-live="polite" hidden></section>
<section id="working-plan" aria-live="polite" aria-label="Current Working Plan" hidden></section>
<div id="input-area">
<textarea id="user-input" placeholder="Type your message..." rows="1" onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendMessage()}"></textarea>
<button onclick="sendMessage()" id="send-btn">Send</button>
Expand All @@ -111,6 +113,7 @@ <h2 id="history-title">Conversation history</h2>
let interactionState = {preference_mode: 'auto', effective_mode: 'ask', system_one_backend: 'off', fast_backend: 'off', pending_question: null, pending_plan: null};
let decisionAssistState = {backend: 'off', kev_private_consent: false};
let systemOneStatus = {system_one_backend: 'off'};
let workingPlan = null;

class AuthRequiredError extends Error {}

Expand Down Expand Up @@ -220,6 +223,44 @@ <h2 id="history-title">Conversation history</h2>
}
}

function renderWorkingPlan(value) {
if (value && workingPlan && value.plan_id === workingPlan.plan_id && value.revision < workingPlan.revision) return;
if (value && workingPlan && value.updated_at && workingPlan.updated_at && value.updated_at < workingPlan.updated_at) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Scope timestamp rejection to the same plan

Only apply this stale-timestamp check when value.plan_id === workingPlan.plan_id. workingPlan is global across restored sessions, so switching from a recently updated session to an older saved session whose plan has an earlier updated_at causes this return to retain and display the previous session's plan even though the fetched plan has a different ID.

Useful? React with 👍 / 👎.

workingPlan = value && typeof value === 'object' ? value : null;
const panel = document.getElementById('working-plan');
panel.replaceChildren();
panel.hidden = !workingPlan;
if (!workingPlan) return;
const title = document.createElement('h2');
title.textContent = `Working Plan · revision ${workingPlan.revision} · ${workingPlan.status}`;
panel.appendChild(title);
if (workingPlan.reason) {
const reason = document.createElement('p');
reason.textContent = `Revision reason: ${workingPlan.reason}`;
panel.appendChild(reason);
}
const list = document.createElement('ol');
for (const task of workingPlan.tasks || []) {
const item = document.createElement('li');
const links = task.superseded_by?.length
? ` · superseded by ${task.superseded_by.map(id => (workingPlan.tasks || []).find(other => other.id === id)?.description || id).join(', ')}` : '';
const blocker = task.status === 'blocked' && task.reason ? ` · ${task.reason}` : '';
item.textContent = `${task.description} · ${task.status}${links}${blocker}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Render successful Web tasks as verified

Translate the succeeded status to the user-facing verified label here, as the TUI does. The Web panel currently renders the raw text succeeded, so the two newly added plan views disagree and the Web UI does not communicate the evidence-backed verification state promised by this change.

Useful? React with 👍 / 👎.

if (task.status === 'cancelled') item.classList.add(task.superseded_by?.length ? 'superseded' : 'cancelled');
list.appendChild(item);
}
panel.appendChild(list);
}

function updateWorkingPlanTask(value) {
if (!workingPlan || !value || typeof value.task_id !== 'string') return;
const task = (workingPlan.tasks || []).find(item => item.id === value.task_id);
if (!task) return;
if (typeof value.status === 'string') task.status = value.status;
if (typeof value.reason === 'string' && value.reason) task.reason = value.reason;
renderWorkingPlan(workingPlan);
}

function renderSystemOneStatus(value) {
systemOneStatus = value || systemOneStatus;
const backend = systemOneStatus.system_one_backend || systemOneStatus.fast_backend || 'off';
Expand Down Expand Up @@ -265,6 +306,7 @@ <h2 id="history-title">Conversation history</h2>
if (data.interaction) renderSystemOneStatus({...systemOneStatus, system_one_backend: data.interaction.system_one_backend,
decision_assist: data.decision_assist || decisionAssistState});
renderInteraction(data.interaction);
renderWorkingPlan(data.working_plan);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the plan after non-streaming controls

Avoid passing an absent field to renderWorkingPlan here. The /api/chat response does not include working_plan (api_chat returns only reply/session/profile/interaction/context data), so every submitControl call clears the currently displayed plan. This also affects the Accept action: the request can create the Working Plan, but the response immediately hides it instead of showing the accepted plan. Include the current snapshot in the response or update the panel only when the field is present.

Useful? React with 👍 / 👎.

renderContext(data.context);
document.getElementById('status').textContent = 'Ready';
} catch (error) { addMessage('assistant', 'Error: ' + error.message, false); renderInteraction(interactionState); document.getElementById('status').textContent = 'Error'; }
Expand Down Expand Up @@ -436,6 +478,7 @@ <h2 id="history-title">Conversation history</h2>
clearChat();
messages.forEach(message => addMessage(message.role, String(message.content), false));
renderInteraction(data.interaction);
renderWorkingPlan(data.working_plan);
renderContext(data.context);
renderSessionList(recentSessions);
document.getElementById('status').textContent = messages.length
Expand All @@ -454,6 +497,7 @@ <h2 id="history-title">Conversation history</h2>
saveActiveSession();
clearChat();
renderInteraction({preference_mode: 'auto', effective_mode: 'ask', system_one_backend: 'off', fast_backend: 'off', pending_question: null, pending_plan: null});
renderWorkingPlan(null);
renderContext(null);
renderSessionList(recentSessions);
document.getElementById('status').textContent = 'New conversation ready.';
Expand Down Expand Up @@ -578,6 +622,7 @@ <h2 id="history-title">Conversation history</h2>
}
try {
const parsed = JSON.parse(data);
if (parsed.session_id && parsed.session_id !== sessionId) return;
if (parsed.event === 'completion' && Object.prototype.hasOwnProperty.call(parsed, 'reply')) {
const finalReply = String(parsed.reply);
if (full !== finalReply) {
Expand All @@ -593,6 +638,9 @@ <h2 id="history-title">Conversation history</h2>
const name = parsed.backend === 'jev' ? 'Jev' : 'Kev';
addMessage('assistant', `Routing advice from ${name}.`, false);
}
if (parsed.event === 'working_plan') renderWorkingPlan(parsed.working_plan);
if (parsed.event === 'tasks') renderWorkingPlan(parsed.working_plan);
if (parsed.event === 'task_update') updateWorkingPlanTask(parsed);
if (parsed.event === 'subagent' && parsed.agent) {
const agent = parsed.agent;
addMessage('assistant', `${agent.name} · ${agent.profile} · ${agent.status}: ${agent.assignment.objective}`, false);
Expand Down
Loading
Loading