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
12 changes: 7 additions & 5 deletions apps/server/src/builder/assistant/files-journey.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { post } from '../../hub/identity/test-hub.ts'
import type { Reply, Sent } from '../../platform/test-anthropic.ts'
import { endOf, messagesOf } from '../conversation/test-conversation.ts'
import { tool } from '../conversation/test-questions.ts'
import type { SentFile } from './test-assistant.ts'
import type { Upload } from './test-assistant.ts'
import { created, talking } from './test-assistant.ts'
import {
COMMAND,
Expand All @@ -21,6 +21,8 @@ import {
} from './test-files.ts'
import { REAL_MODEL, subscribed, textOf } from './test-real-model.ts'

const XLSX_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
const DOCX_TYPE = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
const WRITE = 'mastra_workspace_write_file'
const SHOW = 'show_file'
const PROPOSE = 'propose_app'
Expand All @@ -37,8 +39,8 @@ const QUESTIONS = {
const MERMAID = 'Assim:\n\n```mermaid\nflowchart TD\n A[Pedido] --> B[Entrega]\n```'
const SUMMARY = 'A padaria registra o desconto de cada cliente.'

const sheet = file('precos.xlsx', 'application/octet-stream', Buffer.alloc(4_096, 1))
const document = file('proposta.docx', 'application/octet-stream', Buffer.alloc(4_096, 2))
const sheet = file('precos.xlsx', XLSX_TYPE, Buffer.alloc(4_096, 1))
const document = file('proposta.docx', DOCX_TYPE, Buffer.alloc(4_096, 2))
const bigPdf = file('politica.pdf', PDF_TYPE, Buffer.concat([PDF, Buffer.alloc(200_000, 'x')]))

/** The question of the person's turn the call belongs to, and how many tool calls that turn has made so far. */
Expand Down Expand Up @@ -113,7 +115,7 @@ async function proposalOf(on: Awaited<ReturnType<typeof conversation>>): Promise

test('o roteiro do Assistente do dia a dia', async (t) => {
const on = await conversation(t, journeyModel())
async function said(question: string, files: SentFile[] = []) {
async function said(question: string, files: Upload[] = []) {
assert.equal(endOf(await on.say(question, files)), 'complete')
}
await said(QUESTIONS.rules, [bigPdf, sheet, document])
Expand Down Expand Up @@ -200,7 +202,7 @@ test('o roteiro com modelo de verdade', { timeout: 1_800_000 }, async (t) => {
PDF_TYPE,
readablePdf('Contrato de fornecimento: desconto de 5 por cento acima de 100 unidades.'),
)
async function answered(question: string, files: SentFile[] = []) {
async function answered(question: string, files: Upload[] = []) {
assert.equal(endOf(await say(question, files)), 'complete')
const messages = await messagesOf(on.hub, on.ana, url)
const asked = messages.findLastIndex((message) => message.role === 'person')
Expand Down
9 changes: 9 additions & 0 deletions apps/server/src/builder/assistant/files-machine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,15 @@ test('a máquina fora: o turno falha e o arquivo não vaza', async (t) => {
assert.ok(on.hub.lines.some((line) => line.message === 'MACHINE_UNAVAILABLE'))
})

test('a máquina fora com o arquivo só do histórico: o turno falha em vez de seguir com a nota', async (t) => {
const on = await conversation(t)
assert.equal(endOf(await on.say('Quais as regras?', [pdf])), 'complete')
on.hub.assistantMachines.control.start = 'unreachable'
const events = await on.say('E os prazos?')
assert.deepEqual(failuresOf(events), ['MACHINE_UNAVAILABLE'])
assert.equal(on.calls.length, 1, 'the second question never reached the model')
})

test('a gravação recusada: o turno falha e o arquivo não vaza', async (t) => {
const on = await conversation(t)
mkdirSync(join(on.work, 'uploads'), { recursive: true })
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/builder/assistant/files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { z } from 'zod'
import { nativeRecording } from '../../platform/test-recordings.ts'
import { endOf } from '../conversation/test-conversation.ts'
import { tool } from '../conversation/test-questions.ts'
import type { SentFile } from './test-assistant.ts'
import type { Upload } from './test-assistant.ts'
import {
COMMAND,
conversation,
Expand Down Expand Up @@ -110,7 +110,7 @@ test('o arquivo achado depois da observação', async (t) => {
})

/** The turn on which the observer first ran, for a conversation of Assistant answers as long as `LONG`. */
async function observedAt(t: TestContext, files: SentFile[]) {
async function observedAt(t: TestContext, files: Upload[]) {
const on = await conversation(t, (_, index) => ({ kind: 'text', text: index < 2 ? LONG : 'Li.' }))
const big = files.map(() => file('grande.pdf', PDF_TYPE, Buffer.alloc(200_000, 'x')))
let turn = 0
Expand Down
17 changes: 7 additions & 10 deletions apps/server/src/builder/assistant/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ const PromptFile = z.object({
data: z.union([z.instanceof(Uint8Array), z.string()]),
})

/** Mastra's rule for what it uploads (`isInlineData`): bytes, a data URL or bare base64; a link is left to the model. */
/** Mastra's rule for what it uploads (`isInlineData`), for what the composer sends: bytes or a data URL; a link is left to the model. */
function isInline(data: Uint8Array | string): boolean {
return typeof data !== 'string' || data.startsWith('data:') || !data.includes(':')
}
Expand Down Expand Up @@ -105,7 +105,9 @@ function stamped(messages: readonly Stored[]): void {
* Stamps the note's estimate on the new file parts that are not images (`processInput`,
* `processInputStep`; criterion 10).
*/
export function machineUploads(deps: { log: FailureLog }): InputProcessor {
export function machineUploads(deps: {
log: FailureLog
}): InputProcessor<FileUploadTripwireMetadata> {
return {
id: 'file-upload',
name: 'File Upload',
Expand All @@ -119,8 +121,8 @@ export function machineUploads(deps: { log: FailureLog }): InputProcessor {
async processLLMRequest(args: ProcessLLMRequestArgs<FileUploadTripwireMetadata>) {
const machine = args.workspace
if (machine?.hasSandboxConfig() !== true || !hasPlacedFile(args.prompt)) return undefined
// Started here, so a machine that does not start fails the turn as MACHINE_UNAVAILABLE, not as
// the native NO_SANDBOX tripwire that the screen reads as MODEL_REFUSED.
// Started here, so a machine that is out fails the turn as MACHINE_UNAVAILABLE even when the
// file is only in the history, where the native would leave a note and go on (the card's debt).
if (args.requestContext !== undefined) {
try {
await machine.resolveSandbox({ requestContext: args.requestContext })
Expand All @@ -140,12 +142,7 @@ export function machineUploads(deps: { log: FailureLog }): InputProcessor {
const code = MACHINE_SIDE.find((each) => each === options?.metadata?.code)
if (!args.abortSignal?.aborted && code !== undefined) {
deps.log.error({ reason: 'UPLOAD', code }, 'MACHINE_UNAVAILABLE')
throw told(
args.requestContext,
new Failure('MACHINE_UNAVAILABLE', {
details: { reason: 'UPLOAD', code },
}),
)
throw told(args.requestContext, new Failure('MACHINE_UNAVAILABLE'))
}
return args.abort(reason, options)
}
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/builder/assistant/handoff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { HandedOff } from '@conexus/hub-api'
import { post } from '../../hub/identity/test-hub.ts'
import { endOf, messagesOf, streamOf } from '../conversation/test-conversation.ts'
import { stepped, tool } from '../conversation/test-questions.ts'
import type { SentFile } from './test-assistant.ts'
import type { Upload } from './test-assistant.ts'
import { assisting, created, said, talking } from './test-assistant.ts'

const APP = 'propose_app'
Expand Down Expand Up @@ -42,7 +42,7 @@ async function proposing(
name: string,
place: (on: On) => object = (on) => ({ connections: [on.matriz, on.filial] }),
builder?: Promise<void>,
files: SentFile[] = [],
files: Upload[] = [],
) {
const steps = [
[write('briefing.md', BRIEFING)],
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/builder/assistant/instructions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ test('o texto novo: responder na mensagem, arquivo só quando pedido', async (t)
assert.match(fixed, /uploads folder of your machine/)
assert.match(fixed, /list uploads and read the start of each one/)
assert.match(fixed, /hash of the content/)
assert.match(fixed, /Ignore that suggestion: the document stays on the machine/)
assert.ok(
fixed.indexOf('Your computer') < fixed.indexOf('Install what you lack'),
'the computer comes after how to answer',
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/builder/assistant/prompt/assistant.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@ Make a spreadsheet, a Word document or a presentation only when the person asks

# Files the person sends

Each file the person attaches is written to the uploads folder of your machine, and you get a note with its path, name, type and size. The file itself never comes into the conversation, so read it the way you would read a long file at a terminal: convert it, then read by parts.
Each file the person attaches that is not an image is written to the uploads folder of your machine, and you get a note with its path, name, type and size. That file itself never comes into the conversation (an image you see yourself), so read it the way you would read a long file at a terminal: convert it, then read by parts.

- Convert a PDF with pdftotext and a Word document with pandoc; pypdf and python-docx are installed too. Read a spreadsheet with pandas.
- Search the text with grep, or read a range of lines with read_file, and take only what the question needs. Never load a whole document into the conversation.
- On a PDF or an Office file, read_file answers with its path, size and type only, and suggests an encoding or media. Ignore that suggestion: the document stays on the machine, and you read its text as above.
- The name on disk is a hash of the content, not the name the person gave. When they name a file, list uploads and read the start of each one (pdftotext with a single page, pandoc piped to head) to find it. Only after that say a file is not there.
- Call a file by the name the person gave it.

Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/builder/assistant/test-assistant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { stepped, tool } from '../conversation/test-questions.ts'
type Block = ReturnType<typeof tool>

/** A file the person sends with a message, as the screen sends it: a name, a type and a data URL. */
export type SentFile = { filename: string; mediaType: string; data: string }
export type Upload = { filename: string; mediaType: string; data: string }

export const READ = 'connector_read'
export const COUNT = 'SELECT COUNT(*) AS N FROM TSIEMP'
Expand Down Expand Up @@ -127,7 +127,7 @@ export async function created(hub: Hub, person: Person, assistant: string, body:
export async function talking(t: TestContext, hub: Hub, person: Person, url: string) {
const stream = await streamOf(hub, person, url)
t.after(() => stream.close())
async function say(content: string, files: SentFile[] = []) {
async function say(content: string, files: Upload[] = []) {
const asked =
files.length === 0
? await sent(hub, person, url, content)
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/builder/assistant/test-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { join } from 'node:path'
import type { TestContext } from 'node:test'
import { getMimeType, LocalFilesystem } from '@mastra/core/workspace'
import type { Reply, Sent } from '../../platform/test-anthropic.ts'
import type { SentFile } from './test-assistant.ts'
import type { Upload } from './test-assistant.ts'
import { assisting, created, talking } from './test-assistant.ts'

export { kindOf, sentFilesOf } from './files.ts'
Expand All @@ -25,7 +25,7 @@ export const PNG = Buffer.from(
/** About 24 thousand tokens of the Assistant's own words, so two of them pass the observer's 30 thousand. */
export const LONG = 'As encomendas da padaria chegam pelo telefone e pelo balcão. '.repeat(1800)

export function file(name: string, type: string, bytes: Buffer): SentFile {
export function file(name: string, type: string, bytes: Buffer): Upload {
return {
filename: name,
mediaType: type,
Expand Down
Loading
Loading