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/2742-turns-failure-marker.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A Bot turn now carries its own outcome to the sender instead of leaving it in the wording: an answer says `failure: false`, a turn stopped before it answered says `failure: true` with the stop notice as its reason, and a failed one keeps its failure notice — so a workflow step classifies the step by that marker, a stop notice can no longer advance a run, and an answer that happens to quote a failure sentence can no longer fail one ([#2742](https://github.com/ThinkFlowLab/FlyBot/issues/2742)).
6 changes: 5 additions & 1 deletion core/src/host/turns.js
Original file line number Diff line number Diff line change
Expand Up @@ -721,15 +721,19 @@ export function install(rt) {
} else if (reply !== '' || reason?.kind === 'aborted') {
// A turn stopped before it could answer still owes the sender a line,
// or it would wait forever for a reply that never comes.
const stopped = reply === ''
// #2742: the outcome is the message's own field, not something a reader infers from
// the sentence — an answer says it answered, and a stop says it did not.
void rt.deliver({
fromId: rt.selfOf(id),
toId: source.senderSessionId,
...(back ? { sessionId: back } : {}),
text: reply !== '' ? reply : `${name} was stopped before it could answer your message.`,
text: stopped ? `${name} was stopped before it could answer your message.` : reply,
role: 'reply',
hop: (source.hop ?? 0) + 1,
replyTo: source.exchangeId,
origin,
failure: stopped,
}).catch(error => rt.warn('ds-bot: reply delivery failed: %s', error))
}
}
Expand Down
15 changes: 15 additions & 0 deletions core/test/bot-messages.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,21 @@ test('a joined request in a stopped turn tells its sender, and is not handed bac
assert.equal(delivered[0].toId, 'a')
assert.equal(delivered[0].replyTo, 'e1')
assert.match(delivered[0].text, /was stopped before it could answer/)
// #2742: the outcome travels with the answer, so a step reader never parses this sentence.
assert.equal(delivered[0].failure, true, 'a stopped turn answers with a failure marker')
})

test('#2742: a completed reply carries an explicit success marker', () => {
const { emit, delivered } = turnsHost()
emit('b', 'turn/start', { turn: 1 })
emit('b', 'user/message', { content: textBlock('[Message from Chief]\nstatus?'), source: replySource('request', { exchangeId: 'e1' }) })
emit('b', 'assistant/message', assistantText('Scout abc could not answer your message: its model call failed (RATE_LIMIT 429).'))
emit('b', 'turn/end', { turn: 1, reason: { kind: 'completed' } })
assert.equal(delivered.length, 1)
assert.equal(delivered[0].replyTo, 'e1')
// The answer says what it is, so an answer that happens to quote a failure notice is still an
// answer; without the marker a step reads the sentence and fails the node (#2742).
assert.equal(delivered[0].failure, false, 'an ordinary answer carries failure: false')
})

test('a joined request gets the failure notice when the model call fails', () => {
Expand Down
2 changes: 1 addition & 1 deletion docs/subsystems/core-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi
### Untrusted fences in every shared room

- `authoredRoomLine` fences peer-controlled text by the line's origin, not the room kind (#98): a remote participant, a local remote-chat record (`state.bots[id].remote`), a botId that is no team Bot, or `line.remote`. A friend's Bot speaking in a non-face group is fenced the same way as in a face room. A complete wrap is left as-is so outbound fanout does not nest residue; a mention of the marker in the body still fences. `stripUntrusted` is one fixed-point pass over both `untrusted-message` and `untrusted-file`, and its pattern allows whitespace inside the tag (`< / untrusted-message>`), since a parser that ignores whitespace reads the same marker; `stripMarkers` does the same for one tag. `core/test/fence-property.test.js` fuzzes a corpus of reconstitution attempts to keep this a fixed point. Inbound-share lines from the other instance are `remote: true` so owner-typed text is fenced too. Face join/leave notices stay unfenced platform copy. Channel room previews use `unwrapUntrusted` so the sidebar never shows the wrap. A human line whose `who` is `User` is rewritten from the signed-in display name before it is stored or fenced.
- A late group model failure posts a short friendly line into the room (`This Bot could not answer right now.` / `This Bot has no model set up yet.`); `failureLine` stays on Bot-to-Bot deliver and in diagnostics, not in the human transcript. That Bot-to-Bot failure notice carries `failure: true` (#2677), the same field the remote notice uses, so a consumer reads the outcome from the message instead of parsing the sentence.
- A late group model failure posts a short friendly line into the room (`This Bot could not answer right now.` / `This Bot has no model set up yet.`); `failureLine` stays on Bot-to-Bot deliver and in diagnostics, not in the human transcript. That Bot-to-Bot failure notice carries `failure: true` (#2677), the same field the remote notice uses, so a consumer reads the outcome from the message instead of parsing the sentence. Since #2742 every reply the host completes carries that field: an answer says `failure: false`, and a turn stopped before it answered says `failure: true` with the stop notice as its reason, so no reader has to infer a step's outcome from the wording.

### Remote chats and mentions

Expand Down
1 change: 1 addition & 0 deletions docs/subsystems/workflow.md

Large diffs are not rendered by default.

36 changes: 34 additions & 2 deletions patches/ds-bot/0001-core-fixes.patch
Original file line number Diff line number Diff line change
Expand Up @@ -8959,7 +8959,7 @@ diff --git a/src/host/turns.js b/src/host/turns.js
if (!expectsReply(source) || answersUser(source)) return
const name = rt.botOf(rt.selfOf(id))?.name ?? 'The other Bot'
// A sender that wrote from a group gets the reply in its Session there, while
@@ -434,6 +434,9 @@ export function install(rt) {
@@ -434,21 +434,33 @@ export function install(rt) {
role: 'reply',
hop: (source.hop ?? 0) + 1,
replyTo: source.exchangeId,
Expand All @@ -8969,11 +8969,21 @@ diff --git a/src/host/turns.js b/src/host/turns.js
}).catch(error => rt.warn('ds-bot: failure notice delivery failed: %s', error))
} else if (reply !== '' || reason?.kind === 'aborted') {
// A turn stopped before it could answer still owes the sender a line,
@@ -446,9 +446,14 @@ export function install(rt) {
// or it would wait forever for a reply that never comes.
+ const stopped = reply === ''
+ // #2742: the outcome is the message's own field, not something a reader infers from
+ // the sentence — an answer says it answered, and a stop says it did not.
void rt.deliver({
fromId: rt.selfOf(id),
toId: source.senderSessionId,
...(back ? { sessionId: back } : {}),
- text: reply !== '' ? reply : `${name} was stopped before it could answer your message.`,
+ text: stopped ? `${name} was stopped before it could answer your message.` : reply,
role: 'reply',
hop: (source.hop ?? 0) + 1,
replyTo: source.exchangeId,
+ origin,
+ failure: stopped,
}).catch(error => rt.warn('ds-bot: reply delivery failed: %s', error))
}
}
Expand Down Expand Up @@ -9891,6 +9901,28 @@ diff --git a/test/bot-messages.test.js b/test/bot-messages.test.js
}
installTurns(rt)
let seq = 0
@@ -342,6 +342,21 @@ test('a joined request in a stopped turn tells its sender, and is not handed bac
assert.equal(delivered[0].toId, 'a')
assert.equal(delivered[0].replyTo, 'e1')
assert.match(delivered[0].text, /was stopped before it could answer/)
+ // #2742: the outcome travels with the answer, so a step reader never parses this sentence.
+ assert.equal(delivered[0].failure, true, 'a stopped turn answers with a failure marker')
+})
+
+test('#2742: a completed reply carries an explicit success marker', () => {
+ const { emit, delivered } = turnsHost()
+ emit('b', 'turn/start', { turn: 1 })
+ emit('b', 'user/message', { content: textBlock('[Message from Chief]\nstatus?'), source: replySource('request', { exchangeId: 'e1' }) })
+ emit('b', 'assistant/message', assistantText('Scout abc could not answer your message: its model call failed (RATE_LIMIT 429).'))
+ emit('b', 'turn/end', { turn: 1, reason: { kind: 'completed' } })
+ assert.equal(delivered.length, 1)
+ assert.equal(delivered[0].replyTo, 'e1')
+ // The answer says what it is, so an answer that happens to quote a failure notice is still an
+ // answer; without the marker a step reads the sentence and fails the node (#2742).
+ assert.equal(delivered[0].failure, false, 'an ordinary answer carries failure: false')
})

test('a joined request gets the failure notice when the model call fails', () => {
@@ -356,6 +356,109 @@ test('a joined request gets the failure notice when the model call fails', () =>
assert.match(delivered[0].text, /^Writer could not answer your message: its model call failed \(SERVER 503: upstream busy\)/)
})
Expand Down
10 changes: 10 additions & 0 deletions plugins/workflow/test/step-body.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -179,3 +179,13 @@ test('#2677: an old host without the marker is read only at the start of the tex
assert.equal(await stepOk({ text: 'The model call failed.' }), false)
assert.equal(await stepOk({ text: 'The log said "`<name> could not answer your message: its model call failed`".' }), true, 'a quoted notice after the start is not the step failing')
})

// #2742: every turn the host completes carries its outcome, so the sentence is never read for a
// new host — a stop notice fails the step, and an answer that merely quotes one still advances it.
test('#2742: the host marker decides a stop notice, not its wording', async () => {
const stopNotice = 'Writer was stopped before it could answer your message.'
assert.equal(await stepOk({ text: stopNotice, failure: true }), false, 'a marked stop fails the step')
assert.equal(await stepOk({ text: stopNotice, deliverArgs: { failure: true } }), false, 'the live delivery carries it under deliverArgs')
assert.equal(await stepOk({ text: stopNotice, failure: false }), true, 'a marker that says the answer succeeded wins over the wording')
assert.equal(await stepOk({ text: QUOTED_FAILURE, deliverArgs: { failure: false } }), true, 'an ordinary answer that quotes the notice is still an answer')
})
Loading