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/1993-p2-c5-remaining.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The last five text bounds in `core/src/host/` no longer cut on UTF-16 units ([#1993](https://github.com/ThinkFlowLab/FlyBot/issues/1993)): the quoted first line of a checkpoint's kept tail (80), an approval record's reason (500), a mention receipt's reason line (240), the title given to a DSH Agent's Session (80) and a schedule run's failure reason (200) used a bare `slice`, so a bound that landed between the halves of a surrogate pair stored a lone high surrogate — read as U+FFFD by any encoder. Each now goes through the shared `cut`, which keeps within the same budget the longest prefix that stops before a split pair; none of the five maxima changes.
3 changes: 2 additions & 1 deletion core/src/host/agents.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// the whole dsh conversation surface and none of the team tools.
import { randomUUID } from 'node:crypto'
import { AGENT_NAME, AGENT_SESSION_TITLE_MAX, TEAM_TOOLS } from './constants.js'
import { cut } from './text.js'

export function install(rt) {
const { ctx, state } = rt
Expand Down Expand Up @@ -166,7 +167,7 @@ export function install(rt) {
const clean = String(title ?? '').trim()
if (clean === '') throw new Error('A session needs a title')
const agent_ = await rt.resolveAgent(sessionId)
await ctx.sessionTitle.rename(agent_.session, clean.slice(0, AGENT_SESSION_TITLE_MAX))
await ctx.sessionTitle.rename(agent_.session, cut(clean, AGENT_SESSION_TITLE_MAX).text)
}

async function removeAgent(agentId) {
Expand Down
3 changes: 2 additions & 1 deletion core/src/host/approvals.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { randomUUID } from 'node:crypto'
import { EPHEMERAL_DENIED_TOOLS } from './tools.js'
import { OWNER, settled } from './turn-origin.js'
import { cut } from './text.js'

const OUTCOMES = { allow: 'allowed-once', reject: 'rejected', 'always-bot': 'allowed-once', 'always-room': 'allowed-once' }
const RUN_DENIED = new Set(EPHEMERAL_DENIED_TOOLS)
Expand Down Expand Up @@ -72,7 +73,7 @@ export function install(rt) {
botId,
...(owner ? { roomId: owner.roomId } : {}),
tool: String(request.toolName ?? ''),
reason: String(request.reason ?? '').slice(0, 500),
reason: cut(String(request.reason ?? ''), 500).text,
detail: rt.callDetail?.(sessionId, request.callId) ?? {},
at: Date.now(),
}
Expand Down
2 changes: 1 addition & 1 deletion core/src/host/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -1038,7 +1038,7 @@ export function install(rt) {
// the room, posted as a relay role the room's round machinery does not answer (groups.js `opens`).
async function mentionReceipt(sessionId, room, results, known) {
if (room === undefined || results.length === 0) return
const line = value => String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 240)
const line = value => cut(String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').replace(/\s+/g, ' ').trim(), 240).text
const lines = results.map(({ handle, action, reason }) => {
const name = cleanName(known.get(handle)?.name, handle)
const why = line(reason)
Expand Down
4 changes: 2 additions & 2 deletions core/src/host/schedules.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import { randomUUID } from 'node:crypto'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { quietHoursOf, inQuietHours, clockIn, quietError } from '../shared/quiet-hours.js'
import { defineTool } from './tool-schema.js'
import { codePoints, textOf } from './text.js'
import { codePoints, cut, textOf } from './text.js'
import { output } from './tools.js'
import { retiredBot } from './store.js'

Expand Down Expand Up @@ -279,7 +279,7 @@ export function install(rt) {
for (const id of entry.ids) {
const run = runs()[id]
if (run === undefined) continue
noteRun(id, { ...run, result: reason !== undefined ? 'failed' : aborted ? 'stopped' : 'ran', ...(reason !== undefined ? { reason: reason.slice(0, 200) } : {}) })
noteRun(id, { ...run, result: reason !== undefined ? 'failed' : aborted ? 'stopped' : 'ran', ...(reason !== undefined ? { reason: cut(reason, 200).text } : {}) })
}
}
// A group round whose members' turns failed: the task's run in that group failed.
Expand Down
4 changes: 2 additions & 2 deletions core/src/host/steward.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
//
// Every line follows the model. Its declared window, less the room its reply
// needs, is the usable context; the check line is a share of that.
import { estimateTokens } from './text.js'
import { cut, estimateTokens } from './text.js'

// For a model that declares no window.
export const DEFAULT_WINDOW = 500_000
Expand Down Expand Up @@ -145,7 +145,7 @@ export function checkpointTailNote(tail) {
const first = tail[0]
const text = (Array.isArray(first?.content) ? first.content : [])
.filter(block => block?.type === 'text').map(block => block.text).join(' ').replace(/\s+/g, ' ').trim()
const from = text === '' ? `from your ${first?.role === 'assistant' ? 'own step' : 'latest input'}` : `from the one that begins "${text.slice(0, 80)}"`
const from = text === '' ? `from your ${first?.role === 'assistant' ? 'own step' : 'latest input'}` : `from the one that begins "${cut(text, 80).text}"`
return `The last ${tail.length === 1 ? 'message' : `${tail.length} messages`} above, ${from}, stay right after your summary, word for word. Count what they give as given: summarize only what comes before them, and do not copy them.`
}

Expand Down
14 changes: 14 additions & 0 deletions core/test/agents.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,20 @@ test('agent-rename retitles an owned Session and refuses others', async (t) => {
assert.equal((await call('agent-rename', { agentId: agent.id, sessionId: 'm1', title: 'x' })).ok, false)
})

test('P-1993: a Session title is cut on a code point at the 80-unit bound', async (t) => {
const { call, stop, renamed } = start(TEAM)
t.after(stop)
await call('state', {})
const agent = (await call('add-agent')).value
const head = 'a'.repeat(79)
assert.equal((await call('agent-rename', { agentId: agent.id, sessionId: agent.sessions[0], title: `${head}😀tail` })).ok, true)
assert.ok(renamed.at(-1).title.isWellFormed(), `the title is well formed: ${JSON.stringify(renamed.at(-1).title)}`)
assert.equal(renamed.at(-1).title, head, 'the pair at the bound is dropped whole, not split')
// Control: a title under the bound is stored word for word.
assert.equal((await call('agent-rename', { agentId: agent.id, sessionId: agent.sessions[0], title: ' Scratch ' })).ok, true)
assert.equal(renamed.at(-1).title, 'Scratch')
})

test('remove-agent keeps the Agent with the Sessions it could not archive', async (t) => {
const { call, stop, archived, failArchive } = start(TEAM)
t.after(stop)
Expand Down
30 changes: 30 additions & 0 deletions core/test/flybot.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,36 @@ test('mentions in a group (#80, #81): the room gets a receipt — sent points to
assert.equal((agents.get('b')?.calls ?? []).length, 0)
})

test('P-1993: a mention receipt\'s reason is cut on a code point at 240', async (t) => {
const { ctx, fb, stop, agents, grantTo } = await start({
rooms: { 'room-1': { id: 'room-1', name: 'Group', members: ['a', 'b'], admin: 'a', mode: 'admin', notice: '', createdAt: 1, createdBy: null } },
})
t.after(stop)
await grantTo('a', '@helper.friend.home')
fb.transports.add('bot-social', { send: async msg => ({ exchangeId: `r-${msg.to.handle}` }) })
fb.scope('social').remote.publish([{ handle: '@helper.friend.home', name: '助手', home: '@friend.home' }])
const head = 'a'.repeat(239)
fb.bot.send.use({ name: 'deny-long', handle: m => (m.target?.remote === true ? { action: 'deny', reason: /long/.test(m.text) ? `${head}😀tail` : 'no friendship' } : undefined) })
const receipts = () => (agents.get('room-1')?.calls ?? []).map(([, m]) => m.content.map(b => b.text).join('\n'))
const until = async (check) => {
const end = Date.now() + 2000
while (!check()) {
if (Date.now() > end) throw new Error('until: the condition did not hold within 2 s')
await delay(10)
}
}
const say = async (text, n) => {
ctx.emitArgs('session/event', { id: 'room-1' }, { type: 'user/message', seq: n, data: { source: { kind: 'user' }, content: [{ type: 'text', text }] } })
await until(() => receipts().length >= n)
}
await say('@helper.friend.home long one', 1)
assert.ok(receipts()[0].isWellFormed(), `the receipt is well formed: ${JSON.stringify(receipts()[0])}`)
assert.equal(receipts()[0], `助手: not sent — ${head}`, 'the pair at the bound is dropped whole, not split')
// Control: a reason under the bound is posted word for word.
await say('@helper.friend.home short one', 2)
assert.equal(receipts()[1], '助手: not sent — no friendship')
})

test('mentions (#143): a human line to a friend Bot already in the room stays in the room; Chief is not the sender', async (t) => {
const { ctx, fb, stop, agents } = await start({
rooms: {
Expand Down
18 changes: 18 additions & 0 deletions core/test/group-status.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,24 @@ test('a group Session\'s approval waits in the team view, frees the round, and t
assert.equal(text(post), '[Group post from Writer]\nCopied the doc to E:/docs.')
})

test('P-1993: an approval record\'s reason is cut on a code point at 500', async (t) => {
const host = await start()
t.after(host.stop)
const head = 'a'.repeat(499)
const short = host.askApproval('gb', { callId: 'k1', reason: 'copy the doc' })
const [control] = (await host.call('state', {})).value.work.approvals
assert.equal(control.reason, 'copy the doc', 'a reason under the bound is stored word for word')
assert.equal((await host.call('approval-answer', { id: control.id, choice: 'allow' })).ok, true)
assert.equal(await short, 'allowed-once')

const long = host.askApproval('gb', { callId: 'k2', reason: `${head}😀tail` })
const [approval] = (await host.call('state', {})).value.work.approvals
assert.ok(approval.reason.isWellFormed(), `the stored reason is well formed: ${JSON.stringify(approval.reason)}`)
assert.equal(approval.reason, head, 'the pair at the bound is dropped whole, not split')
assert.equal((await host.call('approval-answer', { id: approval.id, choice: 'allow' })).ok, true)
assert.equal(await long, 'allowed-once')
})

// Both fields of a card go through a bound of their own: 300 for the description, 400 for the
// command. A cut that splits a surrogate pair leaves a lone high half the card draws as U+FFFD.
test('an approval card cuts its description and command at their bounds without splitting a pair', async (t) => {
Expand Down
18 changes: 18 additions & 0 deletions core/test/schedules.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,24 @@ test('a run fails only when its turn errors; a turn without an answer has run, a
assert.equal(saved(), 'stopped')
})

test('P-1993: a failed run\'s reason is cut on a code point at 200', async (t) => {
const schedule = scheduleService()
const { ctx, home, call } = setup({ schedule })
t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) })
await call('schedule-create', { target: 'm1', title: 'Briefing', prompt: 'Sum up', when: DAILY })
const [id] = schedule.tasks.keys()
const message = { id: 'msg-1', content: [{ type: 'text', text: reminder(id, 'Sum up') }], source: { kind: 'schedule' } }
const head = `SERVER 503: ${'a'.repeat(187)}`
runTurn(ctx, 'm1', 1, message, { kind: 'error', error: { code: 'SERVER', status: 503, message: `${'a'.repeat(187)}😀tail` } })
const [[result, reason]] = await lastRuns(call)
assert.equal(result, 'failed')
assert.ok(reason.isWellFormed(), `the stored reason is well formed: ${JSON.stringify(reason)}`)
assert.equal(reason, head, 'the pair at the bound is dropped whole, not split')
// Control: a reason under the bound is stored word for word.
runTurn(ctx, 'm1', 2, { ...message, id: 'msg-2' }, { kind: 'error', error: { code: 'SERVER', status: 503, message: 'upstream busy' } })
assert.deepEqual(await lastRuns(call), [['failed', 'SERVER 503: upstream busy']])
})

test('Retry runs the same prompt once now in the Bot’s current part, and its turn is the task’s run', async (t) => {
const schedule = scheduleService()
const { ctx, home, call } = setup({ schedule })
Expand Down
11 changes: 11 additions & 0 deletions core/test/steward.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,17 @@ test('the checkpoint also reads the messages the backend keeps after it', () =>
assert.ok(checkpointTailNote([say('user', '长'.repeat(300))]).includes(`"${'长'.repeat(80)}"`))
})

test('P-1993: the quoted first line of the kept tail is cut on a code point at 80', () => {
const say = (role, text) => ({ role, content: [{ type: 'text', text }] })
const head = 'a'.repeat(79)
const quoted = note => /begins "(.*)", stay right after your summary/.exec(note)[1]
const long = quoted(checkpointTailNote([say('user', `${head}😀tail`)]))
assert.ok(long.isWellFormed(), `the quoted line is well formed: ${JSON.stringify(long)}`)
assert.equal(long, head, 'the pair at the bound is dropped whole, not split')
// Control: a line under the bound is quoted word for word.
assert.equal(quoted(checkpointTailNote([say('user', 'Short line')])), 'Short line')
})

test('the checkpoint records unanswered requests as remaining, and does not answer them', () => {
assert.match(CHECKPOINT_PROMPT, /every request not yet answered in your replies above/)
assert.match(CHECKPOINT_PROMPT, /The user never sees this summary, so do not do any of the remaining work in it/)
Expand Down
Loading
Loading