Skip to content
Draft
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
46 changes: 15 additions & 31 deletions index.cds
Original file line number Diff line number Diff line change
Expand Up @@ -3,37 +3,20 @@ using {Attachments} from '@cap-js/attachments';

namespace cap.agent;

/**
* Stores A2A task objects for retrieval via tasks/get.
*/
//** Stores A2A task objects for retrieval via tasks/get. */
entity Tasks : managed {
/**
* A2A task ID (server-generated UUID)
*/
key taskId : String;
/**
* Groups related tasks into conversations
*/
// REVISIT: all IDs have to be called ID
key taskId : String; // A2A task ID
contextId : String;
/**
* Current task state (submitted, working, completed, failed, etc.)
*/
state : String;
/**
* Full serialized A2A Task JSON
*/
data : LargeString;
/**
* Fully qualified CDS service name
*/
state : String enum {
submitted;
working;
completed;
failed;
}; // TODO: make the complete list
data : Map; // Full serialized A2A Task JSON
agentService : String;
/**
* Combined LLM Input and Output tokens used for this task
*/
usageLlmTokens : Integer64 default 0;
/**
* Amount of tool calls made by this task
*/
usageToolCalls : Integer default 0;

/** Push notification (webhook) configs for this task. Cascade-deleted. */
Expand Down Expand Up @@ -98,8 +81,9 @@ entity CheckpointWrites {
* and `X-A2A-Notification-Token` header from token field
*/
entity PushNotificationConfigs : managed {
key taskId : String;
key configId : String;
task : Association to one Tasks on task.taskId = taskId;
url : String(2048);
key taskId : String;
key configId : String;
task : Association to one Tasks
on task.taskId = taskId;
url : String(2048);
}
31 changes: 3 additions & 28 deletions lib/agents/markdown/backends/outputs-backend.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,35 +8,10 @@
* files for the task from cap.agent.Tasks.outputFiles and emits them as A2A FileParts.
*
*/
import mime from 'mime-types'

import { isTextMime, globToRegex } from "./mime-utils.js"

function inferMimeType(name) {
const ext = name?.split(".").pop()?.toLowerCase()
const map = {
csv: "text/csv",
txt: "text/plain",
md: "text/markdown",
html: "text/html",
htm: "text/html",
json: "application/json",
xml: "application/xml",
yaml: "application/yaml",
yml: "application/yaml",
pdf: "application/pdf",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
svg: "image/svg+xml",
js: "application/javascript",
ts: "text/typescript",
py: "text/x-python",
sql: "application/sql",
}
return map[ext] || "application/octet-stream"
}

export class OutputsBackend {
constructor(taskId, fileStore) {
this.taskId = taskId
Expand All @@ -52,7 +27,7 @@ export class OutputsBackend {

async write(filePath, content, options = {}) {
const name = this._name(filePath)
const mimeType = options.mimeType || inferMimeType(name)
const mimeType = options.mimeType || mime.lookup(name)
const buf = typeof content === "string" ? Buffer.from(content, "utf-8") : Buffer.from(content)
await this.fileStore.saveOutputFile(this.taskId, name, mimeType, buf)
return { success: true }
Expand Down Expand Up @@ -105,7 +80,7 @@ export class OutputsBackend {
const reGlob = glob ? globToRegex(glob) : null
const files = await this.fileStore.listOutputFiles(this.taskId)
const matches = []
for (const f of files) {
for await (const f of files) {
if (reGlob && !reGlob.test(f.name)) continue
if (!isTextMime(f.mimeType)) continue
const lines = (f.bytes ? Buffer.from(f.bytes).toString("utf-8") : "").split("\n")
Expand Down
83 changes: 55 additions & 28 deletions lib/agents/markdown/backends/uploads-backend.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
// CompositeBackend strips the route prefix (/uploads/) before delegating,
// so paths arrive here as either "/name" (stripped) or "/uploads/name" (direct).
// normalise() handles both forms.
// REVISIT: is this really true ? What about "/uploads/uploads/" ?
_name(filePath) {
return filePath.replace(/^\/uploads\//, "").replace(/^\//, "")
}
Expand All @@ -45,6 +46,11 @@
return { error: `"${name}" is a binary file (${file.mimeType}) — cannot be read as text.` }
}

// REVISIT: `getInputFile` materializes the blob contents into memory
// So for all the previous checks the `bytes` are loaded into memory and not used
// Here the file contents are being used to cut out a specific subset of lines
// Using a `for await` loop would perfectly allow this extraction
// while only holding on to the subset of bytes that have to be returned here
const lines = (file.bytes ? file.bytes.toString("utf-8") : "").split("\n")
return {
content: lines.slice(offset, offset + limit).join("\n"),
Expand All @@ -55,7 +61,7 @@
// Defensive stub for deepagents' BackendProtocolV2: callers that bypass the
// text-only `read()` path (binary handling, future deepagents versions) hit
// this method. Without it, `compositeBackend.readRaw('/uploads/<name>')`
// throws `TypeError: backend.readRaw is not a function`. Currently unused
// thrlineCountows `TypeError: backend.readRaw is not a function`. Currently unused
// by our shipped code paths — do not delete as "dead".
async readRaw(filePath) {
const name = this._name(filePath)
Expand All @@ -66,68 +72,89 @@

// CompositeBackend re-prepends the route prefix to paths returned here,
// so return bare "/<name>" paths (without /uploads/) to avoid double-prefix.
// REVISIT: this comment says to drop the `uploads`, but the code does not do that
async list(_dirPath) {
const files = await this.fileStore.listInputFiles(this.contextId, this.userId)
return files.map((f) => `/${f.name}`)
return files.map((f) => `/${f.name}`) // REVIIST: map is a copy could re use the original array
}

async ls(_path) {
const files = await this.fileStore.listInputFiles(this.contextId, this.userId)
return {
// REVISIT: consider dropping the `fileStore` and simply use proper queries
// that way you can just use `'/' || name as path` in the query instead
files: files.map((f) => ({
path: `/${f.name}`,
is_dir: false,
size: f.size,
modified_at: "",
// not sure why modified_at was not filled
modified_at: file.modifiedAt,

Check failure on line 91 in lib/agents/markdown/backends/uploads-backend.js

View workflow job for this annotation

GitHub Actions / lint

'file' is not defined
})),
}
}

async glob(pattern, _path) {
const re = globToRegex(pattern)
const regex = globToRegex(pattern)
const files = await this.fileStore.listInputFiles(this.contextId, this.userId)
return {
files: files
.filter((f) => re.test(f.name))
.map((f) => ({ path: `/${f.name}`, is_dir: false, size: f.size, modified_at: "" })),
const matches = []
for await (const file of files) { // use asyn iterator to not load the whole list into memory
if (regex.test(file.name)) matches.push({
path: `/${file.name}`,
is_dir: false,
size: file.size,
modified_at: file.modifiedAt,
})
}
return { files: matches }
}

async grep(pattern, _path, glob) {
const reGlob = glob ? globToRegex(glob) : null
const files = await this.fileStore.listInputFiles(this.contextId, this.userId)
const candidates = files.filter(
(f) => (!reGlob || reGlob.test(f.name)) && isTextMime(f.mimeType),
)
const fetched = await Promise.all(
candidates.map((f) => this.fileStore.getInputFile(this.contextId, f.name, this.userId)),
)
const reGlob = glob ? globToRegex(glob) : ''
const regexMime = /^(text\/)/ // TODO: include the expected mime types

const files = this.fileStore.getInputFiles(this.contextId, this.userId, {
xpr: [
...(reGlob ? [{ ref: ['name'] }, 'like', reGlob, 'and'] : []),
mimeType, 'like', regexMime,

Check failure on line 118 in lib/agents/markdown/backends/uploads-backend.js

View workflow job for this annotation

GitHub Actions / lint

'mimeType' is not defined
]
})

const matches = []
for (let i = 0; i < candidates.length; i++) {
const file = fetched[i]
if (!file) continue
const lines = (file.bytes ? file.bytes.toString("utf-8") : "").split("\n")
for (let j = 0; j < lines.length; j++) {
if (lines[j].includes(pattern)) {
matches.push({
path: `/${candidates[i].name}`,
line: j + 1,
text: lines[j],
})
}
for await (const file of files) { // use asyn iterator to not load the whole list into memory
if (!file.content) continue
file.content.setEncoding('utf-8') // should not be really required

let lineCount = 0
let leftover = ''
for await (const chunk of file.content) {
const lines = (leftover + chunk).split('\n')
leftover = lines.pop()
for (const line of lines) match(line)
}
if (leftover) match(leftover)

function match(line) {
lineCount++
if (line.includes(pattern)) matches.push({
path: `/${file.name}`,
line: lineCount,
text: line,
})
}
}
return { matches }
}

async exists(filePath) {
const name = this._name(filePath)
// REVISIT: this currently downloads the whole file contents to check whether it exists
const file = await this.fileStore.getInputFile(this.contextId, name, this.userId)
return !!file
}

async stat(filePath) {
const name = this._name(filePath)
// REVISIT: this currently downloads the whole file contents to just get the metadata
const file = await this.fileStore.getInputFile(this.contextId, name, this.userId)
if (!file) return null
return { name: file.name, mimeType: file.mimeType, size: file.size }
Expand Down
Loading
Loading