diff --git a/CHANGELOG.md b/CHANGELOG.md index ee49f85..02870f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ new version heading in the same commit. ## [Unreleased] +## [0.265.0] — 2026-07-24 +### Changed +- **@mentioning a non-owner agent now asks first (quick answer vs. new session).** Pulling in an agent that + isn't already working the task no longer silently spawns a session. Instead the Discussion shows a choice: + - **Quick answer** — an ephemeral, out-of-band delegate (`ask:`, headless, *not* bound to the + task) reads the task + discussion and posts a concise answer via `task_say`, then exits. + - **New session** — starts the governed session on the task (the previous behaviour). + Mentioning the agent that's *already* on the task still just continues its own session. +- **A human's plain reply answers a pending question on the task's live session.** When an agent asked a + question (`ask_human`) and its session is live on the task, a human reply in the Discussion is fed to the + agent as the answer — replies only feed the session when a question is actually open. + ## [0.264.4] — 2026-07-24 ### Added - **@mention autocomplete in the task Discussion composer.** Typing `@` opens a suggestion menu diff --git a/package-lock.json b/package-lock.json index 88a87a5..badd145 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.264.4", + "version": "0.265.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.264.4", + "version": "0.265.0", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index 886957c..a82e428 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.264.4", + "version": "0.265.0", "description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.", "license": "MIT", "type": "commonjs", diff --git a/src/edge/automations.ts b/src/edge/automations.ts index 9af6797..08f0e4e 100644 --- a/src/edge/automations.ts +++ b/src/edge/automations.ts @@ -886,8 +886,17 @@ export class Automations { for (const tok of mentions) { if (this.os.agents.has(tok)) { if (input.agent && tok === input.agent) continue; // don't pull an agent into its own message - const r = this.continueTaskThread(input.taskId, authorLabel, body, tok, input.runAs ?? t.owner ?? undefined); - agentRuns.push({ agent: tok, status: r.status, sessionId: r.sessionId }); + // Continue the agent ALREADY on this task (its own live/resumable session); otherwise it's a + // NON-owner agent — don't spawn silently, ask the human (Quick answer vs New session). + const boundAgent = t.lastSessionId ? this.tm.sessionAgent(t.lastSessionId) : undefined; + if (boundAgent === tok) { + const r = this.continueTaskThread(input.taskId, authorLabel, body, tok, input.runAs ?? t.owner ?? undefined); + agentRuns.push({ agent: tok, status: r.status, sessionId: r.sessionId }); + } else { + const human = input.author.includes(':') ? (t.owner ?? '') : input.author; + this.tm.postMentionChoice(input.taskId, tok, body, human); + agentRuns.push({ agent: tok, status: 'asked' }); + } continue; } const m = this.tm.memberForMention(tok); @@ -896,9 +905,31 @@ export class Automations { mentionedMembers.push(m.id); } } + // A human's plain reply answers a pending question on the task's live session (feeds it to the agent). + if (!input.agent && t.lastSessionId && this.tm.isAlive(t.lastSessionId)) { + const qid = this.tm.pendingQuestionFor(t.lastSessionId); + if (qid) this.tm.answerQuestion(qid, body, this.os.team.getMember(input.author)?.email ?? input.author); + } return { ok: true, entry, mentionedMembers, agentRuns }; } + /** + * A QUICK, out-of-band answer from a non-owner agent (the "Answer" choice on an @mention) — an ephemeral + * headless delegate (provenance `ask:`, NOT bound to the task) that reads the task + discussion, + * posts a concise answer via `task_say`, and exits. It does NOT take over the task (no `markDispatched`). + */ + quickAnswer(taskId: string, agentId: string, text: string, runAsMember?: string): { ok: boolean; sessionId?: string; reason?: string } { + const t = this.os.tasks.get(taskId); + if (!t) return { ok: false, reason: 'task not found' }; + if (!this.os.agents.has(agentId)) return { ok: false, reason: `unknown agent: ${agentId}` }; + const prompt = `You've been asked for a QUICK ANSWER on task ${t.id} ("${t.title}") — you are NOT taking over the task, just answering a question.\n\n` + + `Read it first with task_get({ id: "${t.id}" }) — it carries the full discussion for context. A teammate asked:\n\n${text}\n\n` + + `Post a concise, direct answer with task_say({ id: "${t.id}", message: "…" }), then stop. Do not start doing the work.`; + const s = this.tm.createSession(agentId, `Answer · ${t.title}`, prompt, `ask:${t.id}`, true, undefined, undefined, t.owner, undefined, false); + this.os.audit.append({ ts: Date.now(), runId: s.id, tenant: this.os.tenant, principal: runAsMember ? `member:${runAsMember}` : 'system', type: 'task.mention.answer', data: { taskId, agent: agentId, session: s.id } }); + return { ok: true, sessionId: s.id }; + } + /** * `@agent` in a task Discussion → put that agent on the task, reusing the thread-continuity engine * (the task's `last_session_id` is the binding a Slack/Discord thread table provides). Live session for diff --git a/src/server.ts b/src/server.ts index da6ab9e..cfcb71f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3121,6 +3121,7 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: const taskComment = p.match(/^\/api\/tasks\/([\w-]+)\/comment$/); const taskMessages = p.match(/^\/api\/tasks\/([\w-]+)\/messages$/); const taskRead = p.match(/^\/api\/tasks\/([\w-]+)\/read$/); + const taskMention = p.match(/^\/api\/tasks\/mention\/([\w-]+)$/); const taskDispatch = p.match(/^\/api\/tasks\/([\w-]+)\/dispatch$/); const taskAttachments = p.match(/^\/api\/tasks\/([\w-]+)\/attachments$/); const taskAttachmentRaw = p.match(/^\/api\/tasks\/[\w-]+\/attachments\/([\w-]+)\/raw$/); @@ -3135,8 +3136,28 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: return sendJson(res, 200, { ...found, attachments: os.tasks.attachments(found.task.id), dependents: os.tasks.dependents(found.task.id), discussion: tm.discussionTimeline(found.task.id), unread: tm.discussionUnread(found.task.id, me), + choices: tm.taskMentionChoices(found.task.id), }); } + // Resolve a non-owner-agent mention choice: 'answer' (quick out-of-band answer), 'session' (start a + // governed session on the task), or 'dismiss'. Any member (like a task edit). + if (taskMention && method === 'POST') { + const b = await readBody(req); + const action = String(b.action || ''); + const c = tm.getMentionChoice(taskMention[1]); + if (!c || c.status !== 'open') return sendJson(res, 404, { error: 'choice not found or already resolved' }); + if (action === 'answer') { + autos.quickAnswer(c.taskId, c.agentId, c.message, me.id); + tm.closeMentionChoice(taskMention[1], 'answered'); + } else if (action === 'session') { + autos.continueTaskThread(c.taskId, me.name || me.email, c.message, c.agentId, me.id); + tm.closeMentionChoice(taskMention[1], 'answered'); + } else if (action === 'dismiss') { + tm.closeMentionChoice(taskMention[1], 'rejected'); + } else return sendJson(res, 400, { error: 'unknown action' }); + os.audit.append({ ts: Date.now(), runId: `task:${c.taskId}`, tenant: os.tenant, principal: me.email, type: 'task.mention.resolved', data: { agent: c.agentId, action } }); + return sendJson(res, 200, { ok: true }); + } // Post a message into a task's Discussion (any member). Parses @mentions → an @member gets an addressed // Inbox card + DM, an @agent is resumed/spawned on the task. Quiet otherwise (not in the Inbox feed). if (taskMessages && method === 'POST') { diff --git a/src/terminal.ts b/src/terminal.ts index dd10758..aca2c71 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -283,7 +283,7 @@ export interface Session { export interface FeedMessage { id: string; - type: 'task' | 'task.chat' | 'update' | 'approval' | 'question' | 'completed' | 'artifact' | 'notification' | 'skill.proposed' | 'goal.proposed' | 'skill.request' | 'secret.request' | 'host.proposed' | 'app.proposed' | 'policy.proposal' | 'automation.proposed' | 'agent.update.proposed'; + type: 'task' | 'task.chat' | 'task.mention' | 'update' | 'approval' | 'question' | 'completed' | 'artifact' | 'notification' | 'skill.proposed' | 'goal.proposed' | 'skill.request' | 'secret.request' | 'host.proposed' | 'app.proposed' | 'policy.proposal' | 'automation.proposed' | 'agent.update.proposed'; sessionId: string; agent: string; title: string; @@ -1281,7 +1281,7 @@ export class TerminalManager { LEFT JOIN questions q ON m.question_id = q.id LEFT JOIN term_sessions ts ON m.session_id = ts.id LEFT JOIN message_state ms ON ms.message_id = m.id AND ms.member_id = ? - WHERE m.dismissed_at IS NULL AND ms.dismissed_at IS NULL AND m.type != 'task.chat' + WHERE m.dismissed_at IS NULL AND ms.dismissed_at IS NULL AND m.type NOT IN ('task.chat', 'task.mention') ORDER BY m.created_at DESC`, ) .all(viewerId); @@ -2973,6 +2973,45 @@ export class TerminalManager { return out; } + /** Record a pending "how should @agent respond?" choice when a NON-owner agent is @mentioned — the + * human picks Quick answer vs New session (see docs/task-rooms-plan.md). Stored as a `task.mention` + * message (Discussion-only, out of the Inbox feed); returns its id. `human` = the member to address. */ + postMentionChoice(taskId: string, agentId: string, text: string, human: string): string { + return this.addMessage({ + type: 'task.mention', sessionId: `task:${taskId}`, agent: agentId, + title: `How should @${agentId} respond?`, body: text, status: 'open', + args: { taskId, agentId }, audienceKind: human ? 'member' : 'admins', audienceId: human || undefined, + }); + } + + /** The open mention-choices on a task (surfaced as a banner in the Discussion). */ + taskMentionChoices(taskId: string): { id: string; agentId: string; message: string }[] { + return this.db + .prepare("SELECT id, agent, body FROM messages WHERE session_id = ? AND type = 'task.mention' AND status = 'open' ORDER BY created_at ASC") + .all<{ id: string; agent: string; body: string }>(`task:${taskId}`) + .map((r) => ({ id: r.id, agentId: r.agent, message: r.body })); + } + + /** Read one mention-choice (for the resolve route). */ + getMentionChoice(msgId: string): { taskId: string; agentId: string; message: string; status: string } | null { + const r = this.db.prepare("SELECT body, status, args FROM messages WHERE id = ? AND type = 'task.mention'").get<{ body: string; status: string; args: string | null }>(msgId); + if (!r) return null; + let a: { taskId?: string; agentId?: string } = {}; + try { a = r.args ? JSON.parse(r.args) : {}; } catch { /* ignore */ } + return { taskId: a.taskId ?? '', agentId: a.agentId ?? '', message: r.body, status: r.status }; + } + + /** Resolve a mention-choice (answered / rejected). */ + closeMentionChoice(msgId: string, status: 'answered' | 'rejected'): void { + this.db.prepare("UPDATE messages SET status = ? WHERE id = ? AND type = 'task.mention'").run(status, msgId); + } + + /** The newest pending `ask_human` question on a session, if any — so a human's plain Discussion reply + * can answer it (Feature: a reply feeds the live session only when a question is open). */ + pendingQuestionFor(sessionId: string): string | undefined { + return this.db.prepare("SELECT id FROM questions WHERE run_id = ? AND status = 'pending' ORDER BY created_at DESC LIMIT 1").get<{ id: string }>(sessionId)?.id; + } + /** Mark a task's Discussion read for a member (per-member upsert over its `task.chat` rows). */ markDiscussionRead(taskId: string, viewer: Member): void { for (const r of this.db diff --git a/web/src/App.tsx b/web/src/App.tsx index 8348f31..f76e2d8 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -7503,7 +7503,7 @@ function TasksPage({ me, agents, taskId, onOpen, nav }: { me: Member; agents: Ag const openTask = (id: string) => nav('tasks', id) const openTaskTab = (id: string, tab: 'discussion' | 'description' | 'session') => nav('tasks', tab === 'discussion' ? id : `${id}/${tab}`) const closeTask = () => { setEditing(false); nav('tasks') } - const [detail, setDetail] = useState<{ task: Task; events: TaskEvent[]; attachments: TaskAttachment[]; dependents: string[]; discussion: TaskTimelineEntry[]; unread: number } | null>(null) + const [detail, setDetail] = useState<{ task: Task; events: TaskEvent[]; attachments: TaskAttachment[]; dependents: string[]; discussion: TaskTimelineEntry[]; unread: number; choices: { id: string; agentId: string; message: string }[] } | null>(null) const [busy, setBusy] = useState(false) const [hint, setHint] = useState('') // view + filters @@ -7613,7 +7613,7 @@ function TasksPage({ me, agents, taskId, onOpen, nav }: { me: Member; agents: Ag useEffect(() => { if (!selId) { setDetail(null); return } if (editing) return // don't overwrite an in-progress edit on a background refresh - api.task(selId).then((r) => { if (r.task) setDetail({ task: r.task, events: r.events ?? [], attachments: r.attachments ?? [], dependents: r.dependents ?? [], discussion: r.discussion ?? [], unread: r.unread ?? 0 }) }) + api.task(selId).then((r) => { if (r.task) setDetail({ task: r.task, events: r.events ?? [], attachments: r.attachments ?? [], dependents: r.dependents ?? [], discussion: r.discussion ?? [], unread: r.unread ?? 0, choices: r.choices ?? [] }) }) }, [selId, tasks, editing]) useEffect(() => { setEditing(false); setConfirmDel(false) }, [selId]) // fresh drawer per selection @@ -7684,10 +7684,10 @@ function TasksPage({ me, agents, taskId, onOpen, nav }: { me: Member; agents: Ag await api.patchTask(detail.task.id, { title: eTitle, body: eBody }) setEditing(false); setBusy(false) await load() - const r = await api.task(detail.task.id); if (r.task) setDetail({ task: r.task, events: r.events ?? [], attachments: r.attachments ?? [], dependents: r.dependents ?? [], discussion: r.discussion ?? [], unread: r.unread ?? 0 }) + const r = await api.task(detail.task.id); if (r.task) setDetail({ task: r.task, events: r.events ?? [], attachments: r.attachments ?? [], dependents: r.dependents ?? [], discussion: r.discussion ?? [], unread: r.unread ?? 0, choices: r.choices ?? [] }) } // Re-pull the open task's detail (events + attachments + dependency edges) after a mutation that doesn't move columns. - const refreshDetail = async (id: string) => { const r = await api.task(id); if (r.task) setDetail({ task: r.task, events: r.events ?? [], attachments: r.attachments ?? [], dependents: r.dependents ?? [], discussion: r.discussion ?? [], unread: r.unread ?? 0 }) } + const refreshDetail = async (id: string) => { const r = await api.task(id); if (r.task) setDetail({ task: r.task, events: r.events ?? [], attachments: r.attachments ?? [], dependents: r.dependents ?? [], discussion: r.discussion ?? [], unread: r.unread ?? 0, choices: r.choices ?? [] }) } if (!tasks) return
Loading…
@@ -7949,7 +7949,23 @@ function TasksPage({ me, agents, taskId, onOpen, nav }: { me: Member; agents: Ag {sessTmux && roomTab_btn('session', <>Session{live && })}
- {activeTab === 'discussion' &&
refreshDetail(t.id)} />
} + {activeTab === 'discussion' && ( +
+ {detail.choices.length > 0 && ( +
+ {detail.choices.map((c) => ( +
+ Pull in @{c.agentId} — quick answer, or a full session on this task? + + + +
+ ))} +
+ )} +
refreshDetail(t.id)} />
+
+ )} {activeTab === 'description' && (
{t.body diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index d31e756..4f26c35 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1399,9 +1399,10 @@ export const api = { kbDelete: (id: string) => call<{ ok: boolean; error?: string }>('DELETE', `/api/kb/page/${id}`), tasks: (q = '', status = '') => call<{ tasks: Task[]; counts: Record; agents: string[]; discussions?: Record }>('GET', `/api/tasks?q=${encodeURIComponent(q)}${status ? `&status=${status}` : ''}`), - task: (id: string) => call<{ task?: Task; events?: TaskEvent[]; attachments?: TaskAttachment[]; dependents?: string[]; discussion?: TaskTimelineEntry[]; unread?: number; error?: string }>('GET', `/api/tasks/${id}`), + task: (id: string) => call<{ task?: Task; events?: TaskEvent[]; attachments?: TaskAttachment[]; dependents?: string[]; discussion?: TaskTimelineEntry[]; unread?: number; choices?: { id: string; agentId: string; message: string }[]; error?: string }>('GET', `/api/tasks/${id}`), postTaskMessage: (id: string, body: string) => call<{ ok: boolean; entry?: TaskTimelineEntry; mentioned?: string[]; agents?: { agent: string; status: string }[]; error?: string }>('POST', `/api/tasks/${id}/messages`, { body }), readTaskDiscussion: (id: string) => call<{ ok: boolean }>('POST', `/api/tasks/${id}/read`), + resolveTaskMention: (msgId: string, action: 'answer' | 'session' | 'dismiss') => call<{ ok: boolean; error?: string }>('POST', `/api/tasks/mention/${msgId}`, { action }), addTask: (b: AddTaskReq) => call<{ ok: boolean; task?: Task; error?: string }>('POST', '/api/tasks', b), patchTask: (id: string, b: { title?: string; body?: string; status?: TaskStatus; assignee?: string | null; priority?: number; labels?: string[]; mode?: 'headless' | 'interactive'; goalId?: string | null; criteria?: string | null; dependsOn?: string[]; dueAt?: number | null; note?: string }) => call<{ ok: boolean; task?: Task; error?: string }>('PATCH', `/api/tasks/${id}`, b), commentTask: (id: string, body: string) => call<{ ok: boolean; task?: Task; error?: string }>('POST', `/api/tasks/${id}/comment`, { body }),