mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-03 04:07:04 +00:00
Show a peer's message in the receiver's block and offer Stop on a tool-only direct turn
Owner 5=A asked for a task-authored message to be visible where it lands: the receiving task's timeline. The event reached events.jsonl and the Logs projection, but the chat reducer rendered it invisible and the supervisor never pushed it live. The row now carries a bounded preview, is pushed to the live log stream, and renders as a visible compact row in the receiver's block. A direct turn that only called tools offered no Stop until the model wrote a narration row, because the host-attested marker rode progress frames only. The turn's own queue proxy now stamps the marker on its tool frames (the same by-value rule that carries the lane fact), and the client grants Stop from any stamped log frame; a turn that neither narrates nor calls tools keeps no block to hang a Stop on. The lane key is declared in ChatOutbound. Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
This commit is contained in:
parent
2722b7b13b
commit
f4ad64b928
11 changed files with 97 additions and 16 deletions
|
|
@ -199,6 +199,11 @@ class ChatOutbound(TypedDict):
|
|||
# endpoint uses, supervisor.workers.direct_chat_turn); never a subagent
|
||||
# frame, never an ephemeral decision turn. Gates the UI "Cancel run" action.
|
||||
cancelable: NotRequired[bool]
|
||||
# The lane fact of a direct conversation turn, stamped by value on the
|
||||
# turn's own frames (supervisor/log_addressing.py TurnEventQueue) so the
|
||||
# chat block reads it before any census: present and true only for a
|
||||
# direct turn's progress/tool frames and on every task_done.
|
||||
_is_direct_chat: NotRequired[bool]
|
||||
# Monetary projections are nullable when the physical-attempt ledger cannot
|
||||
# be read. ``None`` is deliberately distinct from a confirmed $0 result.
|
||||
# C2 (owner 10=B) named these the HONEST names — accounted upper bounds,
|
||||
|
|
@ -640,10 +645,11 @@ class FsDirsResponse(TypedDict):
|
|||
|
||||
|
||||
class TaskNamedOutbound(TypedDict):
|
||||
"""Outbound notice that the proactive card namer coined a project name for a fresh
|
||||
main-chat task (v6.40). The client sets the live card's title to ``suggested_name``;
|
||||
turn-into-project later reuses the same name. Not chat-scoped — carries only
|
||||
``task_id`` and is a no-op unless a thread already holds that card."""
|
||||
"""Outbound notice that a project name was coined for a task's card (the inline
|
||||
naming of a turn-into-project conversion; direct turns are not named in the
|
||||
background). The client sets the live card's title to ``suggested_name``. Not
|
||||
chat-scoped — carries only ``task_id`` and is a no-op unless a thread already
|
||||
holds that card."""
|
||||
|
||||
type: Literal["task_named"]
|
||||
task_id: str
|
||||
|
|
|
|||
|
|
@ -357,6 +357,9 @@ def deliver_task_message(
|
|||
"type": "task_message_injected", "task_id": task_id,
|
||||
"source_task_id": source, "provenance": provenance,
|
||||
"relayed_from_task_id": relayed,
|
||||
# A bounded preview for the receiver's visible timeline row
|
||||
# (owner 5=A); the full text is in the receiver's transcript.
|
||||
"text_preview": str(entry.get("text") or "")[:200],
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -186,7 +186,8 @@ def _handle_send_message(evt: Dict[str, Any], ctx: Any) -> None:
|
|||
if is_progress and evt.get("_is_direct_chat") is True:
|
||||
# Stamped by value on the turn's own queue (TurnEventQueue); the live
|
||||
# frame is built from progress_meta, so the fact rides along.
|
||||
progress_meta = {**(progress_meta or {}), "_is_direct_chat": True}
|
||||
progress_meta = dict(progress_meta or {})
|
||||
progress_meta["_is_direct_chat"] = True
|
||||
_running = getattr(ctx, "RUNNING", None)
|
||||
task_row: Dict[str, Any] = {}
|
||||
if task_id and isinstance(_running, dict):
|
||||
|
|
|
|||
|
|
@ -202,6 +202,13 @@ class TurnEventQueue:
|
|||
# the chat block never wears managed chrome (a Task title, a
|
||||
# conversion control) in the window before the census lists it.
|
||||
data.setdefault("_is_direct_chat", True)
|
||||
# The host-attested Stop marker rides the turn's WORK frames as
|
||||
# it rides its narration rows (events_chat_delivery stamps those
|
||||
# through the same registry): a turn that only calls tools
|
||||
# offers Stop on the block its rows already justify, and a turn
|
||||
# that does neither keeps no block to hang a Stop on.
|
||||
if data.get("type") in ("tool_call_started", "tool_call_finished"):
|
||||
data.setdefault("cancelable", True)
|
||||
return item
|
||||
|
||||
def put(self, item: Any, *args: Any, **kwargs: Any) -> Any:
|
||||
|
|
|
|||
|
|
@ -41,18 +41,28 @@ def _handle_typed_telemetry(evt: Dict[str, Any], ctx: Any) -> None:
|
|||
|
||||
def _handle_task_message_injected(evt: Dict[str, Any], ctx: Any) -> None:
|
||||
"""Log A2A task-message injections so health checks can detect duplicate
|
||||
processing (sibling of owner_message_injected for the task channel)."""
|
||||
processing (sibling of owner_message_injected for the task channel), and
|
||||
push the same row live so the receiving task's chat block shows the
|
||||
message as a timeline row (owner 5=A: visible where it lands)."""
|
||||
payload = {
|
||||
"ts": evt.get("ts", utc_now_iso()),
|
||||
"type": "task_message_injected",
|
||||
"task_id": evt.get("task_id", ""),
|
||||
"source_task_id": evt.get("source_task_id", ""),
|
||||
"provenance": evt.get("provenance", ""),
|
||||
"relayed_from_task_id": evt.get("relayed_from_task_id", ""),
|
||||
"text_preview": str(evt.get("text_preview") or "")[:200],
|
||||
}
|
||||
try:
|
||||
ctx.append_jsonl(ctx.DRIVE_ROOT / "logs" / "events.jsonl", {
|
||||
"ts": evt.get("ts", utc_now_iso()),
|
||||
"type": "task_message_injected",
|
||||
"task_id": evt.get("task_id", ""),
|
||||
"source_task_id": evt.get("source_task_id", ""),
|
||||
"provenance": evt.get("provenance", ""),
|
||||
"relayed_from_task_id": evt.get("relayed_from_task_id", ""),
|
||||
})
|
||||
ctx.append_jsonl(ctx.DRIVE_ROOT / "logs" / "events.jsonl", payload)
|
||||
except Exception:
|
||||
log.warning("Failed to log task_message_injected event", exc_info=True)
|
||||
try:
|
||||
from supervisor.log_addressing import address_ctx_event
|
||||
|
||||
ctx.bridge.push_log(address_ctx_event(ctx, dict(payload)))
|
||||
except Exception:
|
||||
log.debug("Failed to forward task_message_injected to live logs", exc_info=True)
|
||||
|
||||
|
||||
# Merged into supervisor.events.EVENT_HANDLERS (the `**_CEH` pattern). The
|
||||
|
|
|
|||
|
|
@ -467,7 +467,10 @@ def test_turn_event_queue_stamps_by_value_at_the_producer():
|
|||
# The lane fact rides the same events by the same rule (a stamped
|
||||
# task_done keeps its own value; other tasks' events are untouched).
|
||||
assert captured[-1]["data"]["_is_direct_chat"] is True
|
||||
assert "cancelable" not in captured[-1]["data"] # not a work frame
|
||||
assert proxy.stamp({"type": "task_done", "task_id": "turn1", "_is_direct_chat": False})["_is_direct_chat"] is False
|
||||
tool = proxy.stamp({"type": "log_event", "data": {"type": "tool_call_started", "task_id": "turn1", "tool": "read_file"}})
|
||||
assert tool["data"]["cancelable"] is True and tool["data"]["_is_direct_chat"] is True
|
||||
|
||||
# Another task's event and an already-addressed event are left alone.
|
||||
other = {"type": "log_event", "data": {"type": "x", "task_id": "other"}}
|
||||
|
|
|
|||
|
|
@ -329,6 +329,10 @@
|
|||
* @property {string=} task_group_id
|
||||
* @property {string=} task_event
|
||||
* @property {string=} status
|
||||
* @property {boolean=} _is_direct_chat
|
||||
* The lane fact of a direct conversation turn, stamped by the host on the
|
||||
* turn's own progress/tool frames and on every task_done; the chat block
|
||||
* reads it before any census lists the turn.
|
||||
* @property {boolean=} cancelable
|
||||
* v6.82 (P5): host-attested — this frame's task is a supervisor-queue task that
|
||||
* POST /api/tasks/{id}/cancel can force-cancel: a lineage-resolved pooled root or
|
||||
|
|
|
|||
|
|
@ -2120,8 +2120,11 @@ export function createChatInstance({
|
|||
if (childInfo) return Boolean(changed || queued);
|
||||
const subagentChanged = updateSubagentCardFromEvent(evt, rawTs);
|
||||
// The host stamps the lane on the turn's own frames (task_done always,
|
||||
// a direct turn's tool frames too), so chrome never waits for a census.
|
||||
// a direct turn's tool frames too), so chrome never waits for a census;
|
||||
// the host-attested Stop marker rides a direct turn's tool frames the
|
||||
// way it rides its narration rows, so a tool-only turn offers Stop.
|
||||
if (typeof evt._is_direct_chat === 'boolean') noteDirectTurn(liveCardRecords.get(taskId), evt._is_direct_chat);
|
||||
if (evt.cancelable === true) markTaskCancelable(taskId);
|
||||
if (eventType === 'task_done' && summary.terminal) {
|
||||
recordTerminalActivity(taskId);
|
||||
syncChatStatus();
|
||||
|
|
|
|||
|
|
@ -791,6 +791,7 @@ export function summarizeLogEvent(evt) {
|
|||
// independent task / system / escalation).
|
||||
const source = evt.source_task_id ? String(evt.source_task_id) : 'another task';
|
||||
return view('info', `Message from task ${source}`, {
|
||||
body: shortText(evt.text_preview, 200),
|
||||
meta: taskMeta(
|
||||
evt.provenance ? `provenance=${evt.provenance}` : '',
|
||||
evt.relayed_from_task_id ? `relayed=${evt.relayed_from_task_id}` : '',
|
||||
|
|
@ -1246,6 +1247,21 @@ export function summarizeChatLiveEvent(evt) {
|
|||
return chatView({ phase: 'thinking', headline: 'Thinking', dedupeKey: key(evt.round || '', evt.attempt || '') });
|
||||
}
|
||||
|
||||
if (t === 'task_message_injected') {
|
||||
// A message from another task landed in this task's transcript: a
|
||||
// visible row in the receiver's block (owner 5=A), named by value.
|
||||
const source = evt.source_task_id ? String(evt.source_task_id) : 'another task';
|
||||
const preview = String(evt.text_preview || '');
|
||||
return chatView({
|
||||
phase: 'info',
|
||||
headline: `Message from task ${source}`,
|
||||
body: shortText(preview, 200),
|
||||
fullBody: preview,
|
||||
visible: true,
|
||||
dedupeKey: key(source, evt.ts || ''),
|
||||
});
|
||||
}
|
||||
|
||||
if (t === 'tool_call_started' || (t === 'tool_call_finished' && !evt.is_error)) {
|
||||
// A successful call is a compact one-line row: `tool · target`, then
|
||||
// `✓ duration` when it finishes — content the block can stand on,
|
||||
|
|
|
|||
|
|
@ -122,6 +122,22 @@ test('a tool frame stamped with the lane fact mints a direct block before any ce
|
|||
} finally { f.close(); }
|
||||
});
|
||||
|
||||
test('a tool-only direct turn offers Stop from its stamped tool frame, without a narration row', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
f.log({ type: 'llm_round_started', model: 'm', round: 1, _is_direct_chat: true });
|
||||
assert.equal(f.card(), null, 'a round frame is not content and carries no marker');
|
||||
f.log({ type: 'tool_call_started', tool: 'read_file', tool_call_id: 'c1', args: { path: 'README.md' }, _is_direct_chat: true, cancelable: true });
|
||||
assert.ok(f.card(), 'the tool row mints the block');
|
||||
assert.ok(f.card().querySelector('[data-cancel-run]'), 'the host marker on the work frame offers Stop');
|
||||
assert.equal(f.card().dataset.direct, '1');
|
||||
f.census(direct());
|
||||
assert.ok(f.card().querySelector('[data-cancel-run]'));
|
||||
f.log({ ...final, type: 'task_done', status: 'completed', _is_direct_chat: true });
|
||||
assert.equal(f.card()?.querySelector('[data-cancel-run]') ?? null, null, 'no Stop on a finished turn');
|
||||
} finally { f.close(); }
|
||||
});
|
||||
|
||||
test('a direct turn with two successful tools shows two compact rows live and the summary row after a reload', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { categorizeLogEvent, getLogTaskGroupId, isGroupedTaskEvent, summarizeLogEvent } from '../modules/log_events.js';
|
||||
import { categorizeLogEvent, getLogTaskGroupId, isGroupedTaskEvent, summarizeChatLiveEvent, summarizeLogEvent } from '../modules/log_events.js';
|
||||
|
||||
test('a task message landing in a task names its sender in that task timeline', () => {
|
||||
const evt = { type: 'task_message_injected', task_id: 'receiver-1', source_task_id: 'sender-9', provenance: 'independent_task' };
|
||||
|
|
@ -34,3 +34,15 @@ test('a refused task-authored message carries the host reason and warns', () =>
|
|||
assert.equal(view.body, 'target_finished');
|
||||
assert.equal(categorizeLogEvent(evt, view), 'tasks');
|
||||
});
|
||||
|
||||
|
||||
test('a task message landing in a task is a visible row in the receiver\'s chat block, with its preview', () => {
|
||||
const evt = { type: 'task_message_injected', task_id: 'receiver-1', source_task_id: 'sender-9',
|
||||
provenance: 'independent_task', text_preview: 'the PR is ready; please review it', ts: '2026-09-15T12:00:00Z' };
|
||||
const view = summarizeChatLiveEvent(evt);
|
||||
assert.equal(view.visible, true);
|
||||
assert.equal(view.headline, 'Message from task sender-9');
|
||||
assert.equal(view.body, 'the PR is ready; please review it');
|
||||
assert.ok(summarizeLogEvent(evt).body.includes('the PR is ready'));
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue