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
1 change: 1 addition & 0 deletions changelog.d/1154-write-the-decision-back.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A task that names the issue or PR it came from now says where your answer goes, and the Bot writes it there itself ([#1154](https://github.com/ThinkFlowLab/FlyBot/issues/1154)): the result card shows a *Comment goes to …* link, accepting or sending back leaves one comment for the Bot to post with its own GitHub connector, and a comment that could not be posted says so once on the card instead of failing silently.
13 changes: 13 additions & 0 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -687,6 +687,8 @@ var ZH = {
"Send it back": "退回给 Bot",
"What should change": "需要改什么",
"Sent back: {note}": "已退回:{note}",
"Comment goes to {target}": "评论写到 {target}",
"Could not comment on {target}: {error}": "无法在 {target} 上留言:{error}",
// Bot task board (#1058)
Tasks: "任务",
"Close tasks": "关闭任务",
Expand Down Expand Up @@ -7714,6 +7716,8 @@ function InboxCard({ card, roster, now, busy, error, onAnswer, onOpen }) {
const answerable = card.kind === "approval" && card.approval?.answerable === true;
const evidence = card.kind === "task" && card.status === "result" ? card.task?.result?.links ?? [] : [];
const sentBack = card.kind === "task" && card.status === "task" ? card.task?.followUp?.note : void 0;
const target = card.kind === "task" ? card.task?.target?.href : void 0;
const writeBack = card.kind === "task" && card.task?.writeBack?.state === "failed" ? card.task.writeBack : void 0;
return (0, import_react13.createElement)(
"div",
{ className: "bt-inbox-card", role: "group", "aria-label": `${t(STATUSES[card.status].label)}: ${card.title}`, "data-status": card.status },
Expand Down Expand Up @@ -7741,6 +7745,14 @@ function InboxCard({ card, roster, now, busy, error, onAnswer, onOpen }) {
title: href
}, href))) : null,
sentBack ? (0, import_react13.createElement)("span", { className: "bt-inbox-reason", title: sentBack }, t("Sent back: {note}", { note: sentBack })) : null,
target ? (0, import_react13.createElement)("span", { className: "bt-inbox-evidence" }, (0, import_react13.createElement)("a", {
href: target,
target: "_blank",
rel: "noreferrer noopener",
className: "bt-inbox-evidence-link bt-inbox-target",
title: target
}, t("Comment goes to {target}", { target: target.replace(/^https:\/\/github\.com\//, "") }))) : null,
writeBack ? (0, import_react13.createElement)("span", { className: "bt-inbox-writeback", role: "alert", title: writeBack.error }, t("Could not comment on {target}: {error}", { target: writeBack.target, error: writeBack.error })) : null,
(0, import_react13.createElement)(AnswerFields, { card, busy, onAnswer }),
(0, import_react13.createElement)(ResultActions, { card, busy, onAnswer }),
error ? (0, import_react13.createElement)("span", { className: "bt-inbox-error", role: "alert" }, error) : null
Expand Down Expand Up @@ -7876,6 +7888,7 @@ var INBOX_CSS = `
.bt-inbox-calls{font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-variant-numeric:tabular-nums}
.bt-inbox-activity{color:var(--bt-ink-3)}
.bt-inbox-error{font-size:12px;line-height:16px;color:#c21d2e;overflow-wrap:anywhere}
.bt-inbox-writeback{font-size:12px;line-height:16px;color:#c21d2e;overflow-wrap:anywhere}
.bt-inbox-evidence{display:flex;flex-wrap:wrap;gap:6px;font-size:12px;line-height:16px}
.bt-inbox-evidence-link{color:var(--bt-blue,#3a7afe);text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
.bt-inbox-evidence-link:hover{text-decoration:underline}
Expand Down
2 changes: 2 additions & 0 deletions core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,8 @@ const ZH = {
'Send it back': '退回给 Bot',
'What should change': '需要改什么',
'Sent back: {note}': '已退回:{note}',
'Comment goes to {target}': '评论写到 {target}',
'Could not comment on {target}: {error}': '无法在 {target} 上留言:{error}',
// Bot task board (#1058)
Tasks: '任务',
'Close tasks': '关闭任务',
Expand Down
9 changes: 9 additions & 0 deletions core/src/client/inbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,10 @@ export function InboxCard({ card, roster, now, busy, error, onAnswer, onOpen })
const answerable = card.kind === 'approval' && card.approval?.answerable === true
const evidence = card.kind === 'task' && card.status === 'result' ? (card.task?.result?.links ?? []) : []
const sentBack = card.kind === 'task' && card.status === 'task' ? card.task?.followUp?.note : undefined
// Where a decision is written back (#1154): the issue or PR the task came from is on the card, and
// a comment that could not be posted says so once, on the card, instead of staying in a log.
const target = card.kind === 'task' ? card.task?.target?.href : undefined
const writeBack = card.kind === 'task' && card.task?.writeBack?.state === 'failed' ? card.task.writeBack : undefined
return h('div', { className: 'bt-inbox-card', role: 'group', 'aria-label': `${t(STATUSES[card.status].label)}: ${card.title}`, 'data-status': card.status },
h('span', { className: 'bt-inbox-ava' }, card.bot ? h(BotAvatar, { bot: card.bot, size: 28, badge: false, live: false }) : card.room ? h(RoomAvatar, { room: card.room, roster, size: 28 }) : null),
h('span', { className: 'bt-inbox-copy' },
Expand All @@ -459,6 +463,10 @@ export function InboxCard({ card, roster, now, busy, error, onAnswer, onOpen })
key: `${href}-${index}`, href, target: '_blank', rel: 'noreferrer noopener', className: 'bt-inbox-evidence-link', title: href,
}, href))) : null,
sentBack ? h('span', { className: 'bt-inbox-reason', title: sentBack }, t('Sent back: {note}', { note: sentBack })) : null,
target ? h('span', { className: 'bt-inbox-evidence' }, h('a', {
href: target, target: '_blank', rel: 'noreferrer noopener', className: 'bt-inbox-evidence-link bt-inbox-target', title: target,
}, t('Comment goes to {target}', { target: target.replace(/^https:\/\/github\.com\//, '') }))) : null,
writeBack ? h('span', { className: 'bt-inbox-writeback', role: 'alert', title: writeBack.error }, t('Could not comment on {target}: {error}', { target: writeBack.target, error: writeBack.error })) : null,
h(AnswerFields, { card, busy, onAnswer }),
h(ResultActions, { card, busy, onAnswer }),
error ? h('span', { className: 'bt-inbox-error', role: 'alert' }, error) : null),
Expand Down Expand Up @@ -576,6 +584,7 @@ export const INBOX_CSS = `
.bt-inbox-calls{font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-variant-numeric:tabular-nums}
.bt-inbox-activity{color:var(--bt-ink-3)}
.bt-inbox-error{font-size:12px;line-height:16px;color:#c21d2e;overflow-wrap:anywhere}
.bt-inbox-writeback{font-size:12px;line-height:16px;color:#c21d2e;overflow-wrap:anywhere}
.bt-inbox-evidence{display:flex;flex-wrap:wrap;gap:6px;font-size:12px;line-height:16px}
.bt-inbox-evidence-link{color:var(--bt-blue,#3a7afe);text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
.bt-inbox-evidence-link:hover{text-decoration:underline}
Expand Down
19 changes: 12 additions & 7 deletions core/src/host/task-drive.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,16 @@ const fingerprint = (tasks) => tasks.map(task => [
task.waitingFor ?? '',
task.note ?? '',
task.title ?? '',
task.writeBack ? `${task.writeBack.state}:${task.writeBack.target}` : '',
].join('\u0000')).sort().join('\u0001')

const cueFor = (task) => {
const title = String(task.title ?? '').replace(/[\r\n]+/g, ' ').trim().slice(0, 200)
const priority = task.priority ?? 'normal'
return `Continue ${task.id} [${priority}] ${title}. Do this task now. Update it with track_tasks as it moves. Stop when it is done or waiting on an answer. Do not invent other work or start a group round.`
const post = task.writeBack?.state === 'pending'
? ` Then post the decision comment on ${task.writeBack.target} with your GitHub connector and record it with track_tasks postResult {task: '${task.id}', ok: true}, or ok: false with the error.`
: ''
return `Continue ${task.id} [${priority}] ${title}. Do this task now. Update it with track_tasks as it moves. Stop when it is done or waiting on an answer. Do not invent other work or start a group round.${post}`
}

const messageText = (message) => (Array.isArray(message?.content) ? message.content : []).map(block => block?.text ?? '').join('\n')
Expand All @@ -50,12 +54,13 @@ export function install(rt) {
if (!botId) return []
const bot = rt.botOf(botId)
if (!bot || bot.remote !== undefined) return []
return Object.values(rt.state.tasks ?? {}).filter(task =>
task.ownerBotId === botId
&& task.source === 'conversation'
&& task.kind !== 'group'
&& task.waitingFor !== 'input'
&& DRIVING.has(task.status))
return Object.values(rt.state.tasks ?? {}).filter((task) => {
if (task.ownerBotId !== botId || task.waitingFor === 'input') return false
// A decision waiting to be posted is work even when the task is accepted: the Bot holds the
// connector, so the comment goes out on its next turn (#1154).
if (task.writeBack?.state === 'pending') return true
return task.source === 'conversation' && task.kind !== 'group' && DRIVING.has(task.status)
})
}

const roundLive = (roomId) => {
Expand Down
68 changes: 68 additions & 0 deletions core/src/host/tasks.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,34 @@ const readNote = (value) => {
if (value === undefined || value === null) return undefined
return oneLine(value, 300)
}
// Where a decision is written back (#1154): an issue or pull request the task came from. The pointer
// is optional — a task without one keeps its decision on its own record, exactly as before — and it is
// normalised so a `pull/12` link and `owner/repo#12` name the same place.
const TARGET_FORMS = [
/^https?:\/\/(?:www\.)?github\.com\/([\w.-]+)\/([\w.-]+)\/(?:issues|pull)\/(\d+)\/?$/i,
/^([\w.-]+)\/([\w.-]+)#(\d+)$/,
]
const readTarget = (value) => {
if (value === undefined || value === null || String(value).trim() === '') return undefined
const text = oneLine(value, 300)
for (const form of TARGET_FORMS) {
const match = text.match(form)
if (match === null) continue
const [, owner, repo, number] = match
return { owner, repo, number: Number(number), href: `https://github.com/${owner}/${repo}/issues/${Number(number)}` }
}
throw invalid('A target is a GitHub issue or pull request: https://github.com/owner/repo/issues/12 or owner/repo#12')
}
const WRITE_BACK_SUMMARY = 1500
// The comment a decision posts. It records a task decision, so it must not read like a review
// verdict: no `Verdict:` line, no PASS/INC, and `Written-by:` names who decided (#627, #1154).
const writeBackBody = (task, status, note, decider) => {
const lines = [status === 'accepted' ? `Accepted: ${oneLine(task.title, 200)}` : `Sent back: ${oneLine(note, 500)}`]
const summary = oneLine(task.result?.summary ?? '', WRITE_BACK_SUMMARY)
if (summary !== '') lines.push('', summary)
lines.push('', `Decided in FlyBot by ${decider}.`, '', `Written-by: ${decider}`)
return lines.join('\n')
}
const readOptionalLine = (value, name) => {
if (value === undefined || value === null || String(value).trim() === '') return undefined
const text = oneLine(value, 300)
Expand Down Expand Up @@ -115,6 +143,21 @@ export function install(rt) {
if (!mayAct(task, actor)) throw forbidden('Only the requester may move their task')
}

// A decision on a task that names an issue or PR leaves one comment waiting for the Bot that owns
// the task: the Bot holds the connector and the one identity, the host holds no token (#1154). The
// body is composed here so the card and the comment cannot disagree about what was decided.
const decideName = actor => actor?.displayName || actor?.memberId || 'the owner'
const requestWriteBack = (task, status, note, actor) => {
if (task.target === undefined) return
const decider = decideName(actor)
task.writeBack = {
target: task.target.href,
at: task.updatedAt,
state: 'pending',
body: writeBackBody(task, status, note, decider),
}
}

// Owner, a matching grant, or a current human in the named room with that Bot.
const mayCreateOn = (bot, actor, roomId) => {
if (!rt.authzEnforced?.()) return true
Expand Down Expand Up @@ -248,6 +291,11 @@ export function install(rt) {
if (note) task.note = note
else delete task.note
}
if (body.target !== undefined) {
const target = readTarget(body.target)
if (target === undefined) delete task.target
else task.target = target
}
return { priority, progress }
}

Expand Down Expand Up @@ -322,6 +370,24 @@ export function install(rt) {
task.inputRequest = { ...task.inputRequest, answer, answeredAt: task.updatedAt }
}

// The Bot reports how the comment a decision asked for went (#1154). One report per decision, no
// retry loop: a failure keeps its one line on the task so the card can name it, and the decision
// itself is already recorded and stays recorded.
rt.recordTaskPostResult = async function recordTaskPostResult(botId, result) {
const body = result ?? {}
const task = find(body.task ?? body.id)
if (task === undefined) throw stale(`no task ${String(body.task ?? body.id ?? '')}`)
if (task.ownerBotId !== botId) throw forbidden('Only the Bot that owns this task reports its comment')
if (task.writeBack?.state !== 'pending') throw stale('This task has no comment waiting to be posted')
const ok = body.ok === true
const error = oneLine(body.error, 300)
if (!ok && error === '') throw invalid('A comment that failed needs its reason')
task.writeBack = { ...task.writeBack, state: ok ? 'posted' : 'failed', ...(ok ? {} : { error }) }
task.updatedAt = stamp()
await save()
return view(task)
}

// The Bot's own list, written from the conversation. The caller is the Bot.
// The requester of a task is that task's Bot's owner, so a group admin who
// does not own the Bot can plan a subtask and still cannot move the card.
Expand Down Expand Up @@ -646,6 +712,7 @@ export function install(rt) {
if (task === undefined) throw stale(`no task ${String(body.id ?? '')}`)
requireRequester(task, actor)
transition(task, 'accepted', actor)
requestWriteBack(task, 'accepted', undefined, actor)
await save()
return view(task)
}
Expand All @@ -658,6 +725,7 @@ export function install(rt) {
if (note === '') throw invalid('Send back needs a note for the Bot')
transition(task, 'working', actor, { by: 'sent back' })
task.followUp = { note, at: task.updatedAt }
requestWriteBack(task, 'working', note, actor)
await save()
return view(task)
}
Expand Down
19 changes: 19 additions & 0 deletions core/src/host/tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -790,6 +790,13 @@ export function install(rt) {
function formatTasks(tasks, { inGroup, withNext }) {
const ordered = [...tasks].sort(compareForWork)
const cues = []
// A decision the host recorded names the comment it needs (#1154): the Bot holds the connector,
// so the turn that reads this posts it and reports back through postResult.
for (const task of ordered) {
if (task.writeBack?.state !== 'pending') continue
cues.push(`Post ${task.id}: comment on ${task.writeBack.target}, then pass postResult {task: '${task.id}', ok: true} — or ok: false with the error. Body:
${task.writeBack.body}`)
}
for (const task of ordered) {
if (task.waitingFor !== 'input' || !task.inputRequest?.question) continue
cues.push(inGroup
Expand Down Expand Up @@ -835,6 +842,17 @@ export function install(rt) {
assignee: { type: 'string', description: 'Name of the Bot that owns this subtask. Only the group admin, or a Main Bot in the group, may name another member.' },
ask: { type: 'string', description: 'The question you need before this task can continue. The task waits. In your own chat, call ask_user; in a group, ask in your reply.' },
answer: { type: 'string', description: 'What the user or the group answered. Pass it to resume a task that was waiting.' },
target: { type: 'string', description: 'Where a decision on this task is written back: a GitHub issue or pull request, as https://github.com/owner/repo/issues/12 or owner/repo#12. Optional.' },
postResult: {
type: 'object',
description: 'How the decision comment a task asked you to post went. Pass task (its id) and ok; when ok is false, pass the error. One report per decision, and no retrying on its own.',
properties: {
task: { type: 'string', description: 'The task id the comment belongs to.' },
ok: { type: 'boolean', description: 'Whether the comment was posted.' },
error: { type: 'string', description: 'Why it failed, one line. Required when ok is false.' },
},
additionalProperties: false,
},
},
},
},
Expand All @@ -846,6 +864,7 @@ export function install(rt) {
if (!rt.botOf(self)) return 'Only a Bot on the team can track tasks.'
const roomId = rt.groupOwners.get(exec.agent?.id)?.roomId
try {
if (args?.postResult !== undefined) await rt.recordTaskPostResult(self, args.postResult)
const tasks = await rt.trackBotTasks(self, args ?? {}, { sessionId: exec.agent?.id, roomId })
if (!Array.isArray(tasks) || tasks.length === 0) return 'No tasks.'
return formatTasks(tasks, { inGroup: Boolean(roomId), withNext: args?.tasks === undefined })
Expand Down
Loading
Loading