@retinue/agentkit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +310 -0
- package/dist/adapters/bullmq/consumer.d.ts +33 -0
- package/dist/adapters/bullmq/consumer.js +41 -0
- package/dist/adapters/bullmq/dispatcher.d.ts +74 -0
- package/dist/adapters/bullmq/dispatcher.js +160 -0
- package/dist/adapters/bullmq/export.d.ts +31 -0
- package/dist/adapters/bullmq/export.js +53 -0
- package/dist/adapters/bullmq/extraction.d.ts +42 -0
- package/dist/adapters/bullmq/extraction.js +63 -0
- package/dist/adapters/bullmq/index.d.ts +13 -0
- package/dist/adapters/bullmq/index.js +13 -0
- package/dist/adapters/bullmq/lock.d.ts +77 -0
- package/dist/adapters/bullmq/lock.js +126 -0
- package/dist/adapters/bullmq/queue.d.ts +50 -0
- package/dist/adapters/bullmq/queue.js +81 -0
- package/dist/adapters/memory/artifact-exports.d.ts +11 -0
- package/dist/adapters/memory/artifact-exports.js +102 -0
- package/dist/adapters/memory/artifacts.d.ts +15 -0
- package/dist/adapters/memory/artifacts.js +134 -0
- package/dist/adapters/memory/blobs.d.ts +7 -0
- package/dist/adapters/memory/blobs.js +27 -0
- package/dist/adapters/memory/evaluation.d.ts +18 -0
- package/dist/adapters/memory/evaluation.js +148 -0
- package/dist/adapters/memory/files.d.ts +27 -0
- package/dist/adapters/memory/files.js +0 -0
- package/dist/adapters/memory/flows.d.ts +16 -0
- package/dist/adapters/memory/flows.js +117 -0
- package/dist/adapters/memory/hitl.d.ts +9 -0
- package/dist/adapters/memory/hitl.js +130 -0
- package/dist/adapters/memory/idempotency.d.ts +13 -0
- package/dist/adapters/memory/idempotency.js +32 -0
- package/dist/adapters/memory/index.d.ts +39 -0
- package/dist/adapters/memory/index.js +107 -0
- package/dist/adapters/memory/knowledge.d.ts +43 -0
- package/dist/adapters/memory/knowledge.js +248 -0
- package/dist/adapters/memory/mcp.d.ts +9 -0
- package/dist/adapters/memory/mcp.js +37 -0
- package/dist/adapters/memory/message-store.d.ts +17 -0
- package/dist/adapters/memory/message-store.js +70 -0
- package/dist/adapters/memory/principal-memory.d.ts +7 -0
- package/dist/adapters/memory/principal-memory.js +83 -0
- package/dist/adapters/memory/runtime.d.ts +29 -0
- package/dist/adapters/memory/runtime.js +0 -0
- package/dist/adapters/memory/sessions.d.ts +29 -0
- package/dist/adapters/memory/sessions.js +0 -0
- package/dist/adapters/memory/skills.d.ts +10 -0
- package/dist/adapters/memory/skills.js +41 -0
- package/dist/adapters/memory/thread-summary.d.ts +7 -0
- package/dist/adapters/memory/thread-summary.js +29 -0
- package/dist/adapters/memory/usage-limits.d.ts +13 -0
- package/dist/adapters/memory/usage-limits.js +72 -0
- package/dist/adapters/memory/usage.d.ts +16 -0
- package/dist/adapters/memory/usage.js +279 -0
- package/dist/adapters/otel/index.d.ts +111 -0
- package/dist/adapters/otel/index.js +133 -0
- package/dist/adapters/postgres/artifact-exports.d.ts +12 -0
- package/dist/adapters/postgres/artifact-exports.js +117 -0
- package/dist/adapters/postgres/artifacts.d.ts +16 -0
- package/dist/adapters/postgres/artifacts.js +172 -0
- package/dist/adapters/postgres/checkpoint-store.d.ts +16 -0
- package/dist/adapters/postgres/checkpoint-store.js +34 -0
- package/dist/adapters/postgres/config.d.ts +15 -0
- package/dist/adapters/postgres/config.js +187 -0
- package/dist/adapters/postgres/conversation-store.d.ts +4 -0
- package/dist/adapters/postgres/conversation-store.js +82 -0
- package/dist/adapters/postgres/evaluation.d.ts +17 -0
- package/dist/adapters/postgres/evaluation.js +193 -0
- package/dist/adapters/postgres/file-content.d.ts +30 -0
- package/dist/adapters/postgres/file-content.js +111 -0
- package/dist/adapters/postgres/files.d.ts +19 -0
- package/dist/adapters/postgres/files.js +209 -0
- package/dist/adapters/postgres/flows.d.ts +20 -0
- package/dist/adapters/postgres/flows.js +206 -0
- package/dist/adapters/postgres/hitl.d.ts +5 -0
- package/dist/adapters/postgres/hitl.js +247 -0
- package/dist/adapters/postgres/index.d.ts +35 -0
- package/dist/adapters/postgres/index.js +35 -0
- package/dist/adapters/postgres/knowledge.d.ts +48 -0
- package/dist/adapters/postgres/knowledge.js +255 -0
- package/dist/adapters/postgres/memory.d.ts +14 -0
- package/dist/adapters/postgres/memory.js +194 -0
- package/dist/adapters/postgres/message-store.d.ts +11 -0
- package/dist/adapters/postgres/message-store.js +145 -0
- package/dist/adapters/postgres/migrations.d.ts +69 -0
- package/dist/adapters/postgres/migrations.js +1594 -0
- package/dist/adapters/postgres/pg-executor.d.ts +19 -0
- package/dist/adapters/postgres/pg-executor.js +32 -0
- package/dist/adapters/postgres/retention.d.ts +26 -0
- package/dist/adapters/postgres/retention.js +59 -0
- package/dist/adapters/postgres/rollups.d.ts +17 -0
- package/dist/adapters/postgres/rollups.js +267 -0
- package/dist/adapters/postgres/run-coordinator.d.ts +5 -0
- package/dist/adapters/postgres/run-coordinator.js +98 -0
- package/dist/adapters/postgres/run-event-log.d.ts +26 -0
- package/dist/adapters/postgres/run-event-log.js +30 -0
- package/dist/adapters/postgres/run-store.d.ts +4 -0
- package/dist/adapters/postgres/run-store.js +199 -0
- package/dist/adapters/postgres/schema.d.ts +39 -0
- package/dist/adapters/postgres/schema.js +70 -0
- package/dist/adapters/postgres/session-state.d.ts +7 -0
- package/dist/adapters/postgres/session-state.js +99 -0
- package/dist/adapters/postgres/sql.d.ts +8 -0
- package/dist/adapters/postgres/sql.js +2 -0
- package/dist/adapters/postgres/transaction.d.ts +37 -0
- package/dist/adapters/postgres/transaction.js +93 -0
- package/dist/adapters/postgres/unit-of-work.d.ts +18 -0
- package/dist/adapters/postgres/unit-of-work.js +8 -0
- package/dist/adapters/postgres/usage-limits.d.ts +15 -0
- package/dist/adapters/postgres/usage-limits.js +136 -0
- package/dist/adapters/postgres/usage.d.ts +15 -0
- package/dist/adapters/postgres/usage.js +226 -0
- package/dist/adapters/redis/index.d.ts +9 -0
- package/dist/adapters/redis/index.js +9 -0
- package/dist/adapters/redis/realtime.d.ts +74 -0
- package/dist/adapters/redis/realtime.js +112 -0
- package/dist/adapters/supabase/index.d.ts +88 -0
- package/dist/adapters/supabase/index.js +84 -0
- package/dist/adapters/supabase/realtime.d.ts +30 -0
- package/dist/adapters/supabase/realtime.js +53 -0
- package/dist/adapters/supabase/rls.d.ts +99 -0
- package/dist/adapters/supabase/rls.js +216 -0
- package/dist/adapters/supabase/storage.d.ts +50 -0
- package/dist/adapters/supabase/storage.js +207 -0
- package/dist/agents/agent.d.ts +66 -0
- package/dist/agents/agent.js +209 -0
- package/dist/agents/define.d.ts +21 -0
- package/dist/agents/define.js +63 -0
- package/dist/agents/engine.d.ts +98 -0
- package/dist/agents/engine.js +462 -0
- package/dist/agents/index.d.ts +50 -0
- package/dist/agents/index.js +17 -0
- package/dist/artifacts/index.d.ts +114 -0
- package/dist/artifacts/index.js +219 -0
- package/dist/authorization/index.d.ts +76 -0
- package/dist/authorization/index.js +76 -0
- package/dist/capabilities/index.d.ts +120 -0
- package/dist/capabilities/index.js +167 -0
- package/dist/capabilities/runtime.d.ts +89 -0
- package/dist/capabilities/runtime.js +84 -0
- package/dist/citations/index.d.ts +161 -0
- package/dist/citations/index.js +182 -0
- package/dist/context/assembler.d.ts +82 -0
- package/dist/context/assembler.js +129 -0
- package/dist/context/compaction.d.ts +45 -0
- package/dist/context/compaction.js +55 -0
- package/dist/context/index.d.ts +75 -0
- package/dist/context/index.js +17 -0
- package/dist/core/content-parts.d.ts +194 -0
- package/dist/core/content-parts.js +23 -0
- package/dist/core/context.d.ts +51 -0
- package/dist/core/context.js +9 -0
- package/dist/core/env.d.ts +25 -0
- package/dist/core/env.js +41 -0
- package/dist/core/errors.d.ts +30 -0
- package/dist/core/errors.js +65 -0
- package/dist/core/events.d.ts +139 -0
- package/dist/core/events.js +99 -0
- package/dist/core/ids.d.ts +52 -0
- package/dist/core/ids.js +13 -0
- package/dist/core/index.d.ts +9 -0
- package/dist/core/index.js +9 -0
- package/dist/core/tokens.d.ts +22 -0
- package/dist/core/tokens.js +22 -0
- package/dist/core/validation.d.ts +34 -0
- package/dist/core/validation.js +176 -0
- package/dist/documents/extraction.d.ts +121 -0
- package/dist/documents/extraction.js +293 -0
- package/dist/documents/index.d.ts +199 -0
- package/dist/documents/index.js +65 -0
- package/dist/documents/parsers/pdf.d.ts +47 -0
- package/dist/documents/parsers/pdf.js +508 -0
- package/dist/documents/parsers/text.d.ts +59 -0
- package/dist/documents/parsers/text.js +325 -0
- package/dist/documents/read-tool.d.ts +52 -0
- package/dist/documents/read-tool.js +109 -0
- package/dist/documents/render.d.ts +29 -0
- package/dist/documents/render.js +59 -0
- package/dist/documents/vision.d.ts +159 -0
- package/dist/documents/vision.js +214 -0
- package/dist/entries/adapters-bullmq.d.ts +8 -0
- package/dist/entries/adapters-bullmq.js +8 -0
- package/dist/entries/adapters-otel.d.ts +13 -0
- package/dist/entries/adapters-otel.js +13 -0
- package/dist/entries/adapters-postgres.d.ts +10 -0
- package/dist/entries/adapters-postgres.js +10 -0
- package/dist/entries/adapters-redis.d.ts +3 -0
- package/dist/entries/adapters-redis.js +3 -0
- package/dist/entries/context.d.ts +20 -0
- package/dist/entries/context.js +20 -0
- package/dist/entries/flows.d.ts +15 -0
- package/dist/entries/flows.js +15 -0
- package/dist/entries/hitl.d.ts +10 -0
- package/dist/entries/hitl.js +10 -0
- package/dist/entries/knowledge.d.ts +18 -0
- package/dist/entries/knowledge.js +19 -0
- package/dist/entries/mcp.d.ts +10 -0
- package/dist/entries/mcp.js +10 -0
- package/dist/entries/observability.d.ts +14 -0
- package/dist/entries/observability.js +16 -0
- package/dist/entries/persistence.d.ts +11 -0
- package/dist/entries/persistence.js +11 -0
- package/dist/entries/providers.d.ts +14 -0
- package/dist/entries/providers.js +14 -0
- package/dist/entries/runtime.d.ts +13 -0
- package/dist/entries/runtime.js +13 -0
- package/dist/entries/server.d.ts +24 -0
- package/dist/entries/server.js +24 -0
- package/dist/entries/tools.d.ts +21 -0
- package/dist/entries/tools.js +21 -0
- package/dist/entries/usage.d.ts +10 -0
- package/dist/entries/usage.js +10 -0
- package/dist/evaluation/gate.d.ts +168 -0
- package/dist/evaluation/gate.js +180 -0
- package/dist/evaluation/graders.d.ts +125 -0
- package/dist/evaluation/graders.js +203 -0
- package/dist/evaluation/index.d.ts +120 -0
- package/dist/evaluation/index.js +183 -0
- package/dist/evaluation/judge.d.ts +75 -0
- package/dist/evaluation/judge.js +111 -0
- package/dist/export/index.d.ts +162 -0
- package/dist/export/index.js +363 -0
- package/dist/export/markdown.d.ts +19 -0
- package/dist/export/markdown.js +29 -0
- package/dist/export/pdf.d.ts +73 -0
- package/dist/export/pdf.js +407 -0
- package/dist/files/context.d.ts +97 -0
- package/dist/files/context.js +185 -0
- package/dist/files/index.d.ts +210 -0
- package/dist/files/index.js +338 -0
- package/dist/files/read-tool.d.ts +81 -0
- package/dist/files/read-tool.js +163 -0
- package/dist/files/turn-parts.d.ts +96 -0
- package/dist/files/turn-parts.js +171 -0
- package/dist/flows/index.d.ts +270 -0
- package/dist/flows/index.js +62 -0
- package/dist/flows/interpreter.d.ts +146 -0
- package/dist/flows/interpreter.js +426 -0
- package/dist/flows/runner.d.ts +145 -0
- package/dist/flows/runner.js +270 -0
- package/dist/graphql/index.d.ts +8 -0
- package/dist/graphql/index.js +8 -0
- package/dist/graphql/resolvers.d.ts +237 -0
- package/dist/graphql/resolvers.js +253 -0
- package/dist/graphql/schema.d.ts +11 -0
- package/dist/graphql/schema.js +258 -0
- package/dist/graphql/sse.d.ts +77 -0
- package/dist/graphql/sse.js +100 -0
- package/dist/hitl/approved-execution.d.ts +127 -0
- package/dist/hitl/approved-execution.js +177 -0
- package/dist/hitl/index.d.ts +79 -0
- package/dist/hitl/index.js +12 -0
- package/dist/hitl/service.d.ts +221 -0
- package/dist/hitl/service.js +268 -0
- package/dist/idempotency/index.d.ts +70 -0
- package/dist/idempotency/index.js +59 -0
- package/dist/index.d.ts +103 -0
- package/dist/index.js +60 -0
- package/dist/knowledge/chunking.d.ts +57 -0
- package/dist/knowledge/chunking.js +158 -0
- package/dist/knowledge/index.d.ts +119 -0
- package/dist/knowledge/index.js +166 -0
- package/dist/knowledge/retrieval.d.ts +146 -0
- package/dist/knowledge/retrieval.js +170 -0
- package/dist/loadtest/harness.d.ts +168 -0
- package/dist/loadtest/harness.js +507 -0
- package/dist/loadtest/index.d.ts +13 -0
- package/dist/loadtest/index.js +13 -0
- package/dist/loadtest/injection.d.ts +89 -0
- package/dist/loadtest/injection.js +147 -0
- package/dist/loadtest/metrics.d.ts +197 -0
- package/dist/loadtest/metrics.js +160 -0
- package/dist/loadtest/runbooks.d.ts +28 -0
- package/dist/loadtest/runbooks.js +159 -0
- package/dist/loadtest/scenario.d.ts +104 -0
- package/dist/loadtest/scenario.js +208 -0
- package/dist/mcp/egress.d.ts +53 -0
- package/dist/mcp/egress.js +115 -0
- package/dist/mcp/index.d.ts +93 -0
- package/dist/mcp/index.js +33 -0
- package/dist/mcp/provider.d.ts +62 -0
- package/dist/mcp/provider.js +0 -0
- package/dist/models/index.d.ts +98 -0
- package/dist/models/index.js +74 -0
- package/dist/models/pricing.d.ts +24 -0
- package/dist/models/pricing.js +37 -0
- package/dist/models/provider-factory.d.ts +31 -0
- package/dist/models/provider-factory.js +67 -0
- package/dist/models/streaming.d.ts +145 -0
- package/dist/models/streaming.js +272 -0
- package/dist/models/vision.d.ts +38 -0
- package/dist/models/vision.js +62 -0
- package/dist/persistence/index.d.ts +1654 -0
- package/dist/persistence/index.js +226 -0
- package/dist/principal-memory/index.d.ts +106 -0
- package/dist/principal-memory/index.js +89 -0
- package/dist/retention/index.d.ts +89 -0
- package/dist/retention/index.js +70 -0
- package/dist/runtime/checkpoint.d.ts +37 -0
- package/dist/runtime/checkpoint.js +22 -0
- package/dist/runtime/index.d.ts +118 -0
- package/dist/runtime/index.js +69 -0
- package/dist/runtime/retry.d.ts +95 -0
- package/dist/runtime/retry.js +126 -0
- package/dist/runtime/serialization.d.ts +85 -0
- package/dist/runtime/serialization.js +95 -0
- package/dist/runtime/streaming.d.ts +54 -0
- package/dist/runtime/streaming.js +115 -0
- package/dist/runtime/worker.d.ts +130 -0
- package/dist/runtime/worker.js +405 -0
- package/dist/security/checklist.d.ts +53 -0
- package/dist/security/checklist.js +204 -0
- package/dist/security/findings.d.ts +56 -0
- package/dist/security/findings.js +168 -0
- package/dist/security/index.d.ts +14 -0
- package/dist/security/index.js +14 -0
- package/dist/security/prompt-safety.d.ts +100 -0
- package/dist/security/prompt-safety.js +133 -0
- package/dist/server/boot.d.ts +32 -0
- package/dist/server/boot.js +36 -0
- package/dist/server/cli-worker.d.ts +37 -0
- package/dist/server/cli-worker.js +151 -0
- package/dist/server/cli.d.ts +27 -0
- package/dist/server/cli.js +74 -0
- package/dist/server/config.d.ts +42 -0
- package/dist/server/config.js +127 -0
- package/dist/server/health.d.ts +59 -0
- package/dist/server/health.js +90 -0
- package/dist/server/host.d.ts +39 -0
- package/dist/server/host.js +124 -0
- package/dist/server/index.d.ts +15 -0
- package/dist/server/index.js +15 -0
- package/dist/server/main.d.ts +16 -0
- package/dist/server/main.js +31 -0
- package/dist/server/sse-route.d.ts +21 -0
- package/dist/server/sse-route.js +282 -0
- package/dist/skills/index.d.ts +67 -0
- package/dist/skills/index.js +31 -0
- package/dist/skills/resolver.d.ts +54 -0
- package/dist/skills/resolver.js +121 -0
- package/dist/teams/index.d.ts +93 -0
- package/dist/teams/index.js +207 -0
- package/dist/telemetry/index.d.ts +157 -0
- package/dist/telemetry/index.js +71 -0
- package/dist/telemetry/instrument.d.ts +108 -0
- package/dist/telemetry/instrument.js +232 -0
- package/dist/telemetry/log-events.d.ts +17 -0
- package/dist/telemetry/log-events.js +58 -0
- package/dist/telemetry/metrics.d.ts +123 -0
- package/dist/telemetry/metrics.js +135 -0
- package/dist/telemetry/noop.d.ts +39 -0
- package/dist/telemetry/noop.js +143 -0
- package/dist/telemetry/redaction.d.ts +64 -0
- package/dist/telemetry/redaction.js +153 -0
- package/dist/telemetry/spans.d.ts +56 -0
- package/dist/telemetry/spans.js +78 -0
- package/dist/telemetry/trace-context.d.ts +55 -0
- package/dist/telemetry/trace-context.js +60 -0
- package/dist/toolkit/compute.d.ts +53 -0
- package/dist/toolkit/compute.js +152 -0
- package/dist/toolkit/data.d.ts +98 -0
- package/dist/toolkit/data.js +235 -0
- package/dist/toolkit/http.d.ts +113 -0
- package/dist/toolkit/http.js +205 -0
- package/dist/toolkit/index.d.ts +21 -0
- package/dist/toolkit/index.js +17 -0
- package/dist/toolkit/web.d.ts +107 -0
- package/dist/toolkit/web.js +147 -0
- package/dist/tools/define.d.ts +25 -0
- package/dist/tools/define.js +45 -0
- package/dist/tools/delegating.d.ts +132 -0
- package/dist/tools/delegating.js +211 -0
- package/dist/tools/index.d.ts +129 -0
- package/dist/tools/index.js +33 -0
- package/dist/tools/library/compute.d.ts +11 -0
- package/dist/tools/library/compute.js +46 -0
- package/dist/tools/library/data.d.ts +16 -0
- package/dist/tools/library/data.js +92 -0
- package/dist/tools/library/http.d.ts +28 -0
- package/dist/tools/library/http.js +71 -0
- package/dist/tools/library/index.d.ts +97 -0
- package/dist/tools/library/index.js +134 -0
- package/dist/tools/library/knowledge.d.ts +39 -0
- package/dist/tools/library/knowledge.js +58 -0
- package/dist/tools/library/web.d.ts +19 -0
- package/dist/tools/library/web.js +65 -0
- package/dist/tools/meta-tools.d.ts +19 -0
- package/dist/tools/meta-tools.js +36 -0
- package/dist/tools/registry.d.ts +146 -0
- package/dist/tools/registry.js +291 -0
- package/dist/usage/index.d.ts +105 -0
- package/dist/usage/index.js +20 -0
- package/dist/usage/quota.d.ts +258 -0
- package/dist/usage/quota.js +510 -0
- package/dist/usage/recorder.d.ts +29 -0
- package/dist/usage/recorder.js +96 -0
- package/dist/usage/rollups.d.ts +121 -0
- package/dist/usage/rollups.js +157 -0
- package/dist/worker/export.d.ts +57 -0
- package/dist/worker/export.js +81 -0
- package/dist/worker/extraction.d.ts +57 -0
- package/dist/worker/extraction.js +84 -0
- package/dist/worker/main.d.ts +103 -0
- package/dist/worker/main.js +159 -0
- package/package.json +187 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The attachment lifecycle — `docs/05-knowledge-and-documents.md`, REQ-026 (#129).
|
|
3
|
+
*
|
|
4
|
+
* The ports are in `persistence/`; what lives here is the sequencing that makes them safe together, and it
|
|
5
|
+
* is the part that cannot be a rule someone remembers:
|
|
6
|
+
*
|
|
7
|
+
* - an upload is two writes, metadata then bytes, and the window between them is where orphans live
|
|
8
|
+
* - a declared size is a claim, and the only real defence is a cap enforced while reading
|
|
9
|
+
* - object storage cannot join a database transaction, so deletion is scheduled rather than performed
|
|
10
|
+
*/
|
|
11
|
+
import { AgentPlatformError } from "../core/errors.js";
|
|
12
|
+
import { asId } from "../core/ids.js";
|
|
13
|
+
/**
|
|
14
|
+
* The ceiling on a signed URL's life.
|
|
15
|
+
*
|
|
16
|
+
* A signed URL is a bearer token in a query string: it goes into logs, into a browser's history, and — if a
|
|
17
|
+
* tool result ever carried one — into the run event log, where anyone who can read the conversation can read
|
|
18
|
+
* it long after the check that produced it. #118 refused to return one from a tool for exactly that reason.
|
|
19
|
+
*
|
|
20
|
+
* Fifteen minutes is long enough to load a document and short enough that a leaked URL is usually already
|
|
21
|
+
* dead. `signedReadUrl` clamps rather than trusting the caller.
|
|
22
|
+
*/
|
|
23
|
+
export const MAX_SIGNED_URL_SECONDS = 900;
|
|
24
|
+
/** A conservative default set. A deployment narrows or widens it deliberately. */
|
|
25
|
+
export const DEFAULT_UPLOAD_LIMITS = {
|
|
26
|
+
maxBytes: 25 * 1024 * 1024,
|
|
27
|
+
allowedMediaTypes: [
|
|
28
|
+
"image/png",
|
|
29
|
+
"image/jpeg",
|
|
30
|
+
"image/webp",
|
|
31
|
+
"image/gif",
|
|
32
|
+
"application/pdf",
|
|
33
|
+
"text/plain",
|
|
34
|
+
"text/csv",
|
|
35
|
+
"text/markdown",
|
|
36
|
+
],
|
|
37
|
+
signedUrlSeconds: 300,
|
|
38
|
+
};
|
|
39
|
+
const refuse = (code, message) => new AgentPlatformError({ code, message, retryable: false });
|
|
40
|
+
/**
|
|
41
|
+
* The cheap early refusal — AC-2's first half.
|
|
42
|
+
*
|
|
43
|
+
* *"Refused before the bytes are accepted, with the limit stated."* The limit is in the message because an
|
|
44
|
+
* error saying "too large" sends someone to guess; one saying "25 MB" does not.
|
|
45
|
+
*
|
|
46
|
+
* **This check can only ever see the declared size.** A client that declares 1 KB and sends 1 GB passes it,
|
|
47
|
+
* which is why `streamWithCap` exists and is not optional.
|
|
48
|
+
*/
|
|
49
|
+
export const validateUpload = (input, limits) => {
|
|
50
|
+
// Normalised, because a browser sends `text/plain; charset=utf-8` and a bare-string comparison would
|
|
51
|
+
// refuse it — a refusal the user cannot act on, for a file that is fine.
|
|
52
|
+
const mediaType = input.mediaType.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
53
|
+
if (!limits.allowedMediaTypes.includes(mediaType))
|
|
54
|
+
throw refuse("invalid_input", `${mediaType || "that file type"} is not accepted. Accepted types: ${limits.allowedMediaTypes.join(", ")}`);
|
|
55
|
+
if (!Number.isFinite(input.declaredBytes) || input.declaredBytes <= 0)
|
|
56
|
+
throw refuse("invalid_input", "the file's size must be stated and greater than zero");
|
|
57
|
+
if (input.declaredBytes > limits.maxBytes)
|
|
58
|
+
throw refuse("invalid_input", `that file is ${input.declaredBytes} bytes and the limit is ${limits.maxBytes} bytes`);
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* AC-2's second half, and the one that holds.
|
|
62
|
+
*
|
|
63
|
+
* Wraps a byte stream so it **stops** at the cap rather than reading to the end and then complaining. The
|
|
64
|
+
* difference is the whole point: reading a hostile 1 GB body into memory and *then* returning an error is a
|
|
65
|
+
* denial of service that happens to report itself politely.
|
|
66
|
+
*
|
|
67
|
+
* `safefetch.py` in ShareFlow exists for the same reason, and says it plainly: *"the callers parse whole
|
|
68
|
+
* documents in memory, so an unbounded download is a denial-of-service vector on a container capped at
|
|
69
|
+
* 600 MB."*
|
|
70
|
+
*/
|
|
71
|
+
export const streamWithCap = async function* (bytes, maxBytes) {
|
|
72
|
+
let seen = 0;
|
|
73
|
+
for await (const chunk of bytes) {
|
|
74
|
+
seen += chunk.byteLength;
|
|
75
|
+
if (seen > maxBytes) {
|
|
76
|
+
// Thrown from inside the generator, so the consumer stops pulling and the producer is cancelled. The
|
|
77
|
+
// partial object is the adapter's to discard — `putFile` says so.
|
|
78
|
+
throw refuse("invalid_input", `the file exceeds the ${maxBytes} byte limit`);
|
|
79
|
+
}
|
|
80
|
+
yield chunk;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* The attachment lifecycle.
|
|
85
|
+
*
|
|
86
|
+
* Every method takes an `ExecutionContext`, and entitlement to a file *is* entitlement to its conversation —
|
|
87
|
+
* so there is no second permission model to keep in step with the first.
|
|
88
|
+
*/
|
|
89
|
+
export const createFileService = (deps) => {
|
|
90
|
+
const limits = deps.limits ?? DEFAULT_UPLOAD_LIMITS;
|
|
91
|
+
const clock = deps.clock ?? (() => new Date().toISOString());
|
|
92
|
+
const newKey = deps.contentKey ?? (() => `f_${crypto.randomUUID()}`);
|
|
93
|
+
const newId = deps.fileId ?? (() => `file_${crypto.randomUUID()}`);
|
|
94
|
+
return {
|
|
95
|
+
limits,
|
|
96
|
+
/**
|
|
97
|
+
* Upload: validate, record `pending`, write bytes, then move to `stored`.
|
|
98
|
+
*
|
|
99
|
+
* The order is the point, and the alternative is worse in a specific way. Writing bytes first and
|
|
100
|
+
* metadata second means a crash between them leaves an object nothing references — invisible, and
|
|
101
|
+
* billed for. Metadata first means a crash leaves a row in `pending`, which reconciliation can see and
|
|
102
|
+
* a user cannot. **An orphan you can find beats an orphan you cannot.**
|
|
103
|
+
*/
|
|
104
|
+
async upload(context, input) {
|
|
105
|
+
validateUpload({ mediaType: input.mediaType, declaredBytes: input.declaredBytes }, limits);
|
|
106
|
+
// Before anything is written. An unentitled upload that got as far as creating a `pending` row would
|
|
107
|
+
// put a filename of someone else's choosing into a conversation they cannot read.
|
|
108
|
+
const decision = await deps.authorization.can(context, "write", {
|
|
109
|
+
type: "conversation",
|
|
110
|
+
id: input.conversationId,
|
|
111
|
+
});
|
|
112
|
+
if (!decision.allow)
|
|
113
|
+
throw refuse("not_found", "no such conversation");
|
|
114
|
+
const id = asId(newId());
|
|
115
|
+
const contentKey = newKey();
|
|
116
|
+
const createdAt = clock();
|
|
117
|
+
const pending = {
|
|
118
|
+
id,
|
|
119
|
+
conversationId: input.conversationId,
|
|
120
|
+
filename: input.filename,
|
|
121
|
+
mediaType: input.mediaType,
|
|
122
|
+
byteSize: input.declaredBytes,
|
|
123
|
+
contentKey,
|
|
124
|
+
state: "pending",
|
|
125
|
+
uploadedBy: context.principalId,
|
|
126
|
+
createdAt,
|
|
127
|
+
};
|
|
128
|
+
await deps.metadata.create({ tenantId: context.tenantId, file: pending });
|
|
129
|
+
// A failure here leaves the row in `pending`, deliberately and with no cleanup. That row is the
|
|
130
|
+
// evidence reconciliation uses to find a partial object; deleting it would hide exactly the case this
|
|
131
|
+
// ordering exists to make visible.
|
|
132
|
+
const stored = await deps.content.putFile({
|
|
133
|
+
tenantId: context.tenantId,
|
|
134
|
+
contentKey,
|
|
135
|
+
mediaType: input.mediaType,
|
|
136
|
+
bytes: streamWithCap(input.bytes, limits.maxBytes),
|
|
137
|
+
maxBytes: limits.maxBytes,
|
|
138
|
+
});
|
|
139
|
+
const moved = await deps.metadata.transition({
|
|
140
|
+
tenantId: context.tenantId,
|
|
141
|
+
id,
|
|
142
|
+
from: "pending",
|
|
143
|
+
to: "stored",
|
|
144
|
+
at: clock(),
|
|
145
|
+
checksum: stored.checksum,
|
|
146
|
+
});
|
|
147
|
+
if (!moved.moved) {
|
|
148
|
+
// Something else moved it — a conversation deleted mid-upload is the realistic case. The bytes are
|
|
149
|
+
// now unreferenced, so they are removed here rather than left for the sweep.
|
|
150
|
+
await deps.content.deleteFile({ tenantId: context.tenantId, contentKey });
|
|
151
|
+
throw refuse("conflict", "that conversation was deleted while the file was uploading");
|
|
152
|
+
}
|
|
153
|
+
// Extraction is *requested*, not performed — AC-2 of #131. Deliberately after the transition to
|
|
154
|
+
// `stored`, so a worker picking the job up immediately finds a file it can read.
|
|
155
|
+
//
|
|
156
|
+
// The `catch` is not laziness. The upload has succeeded; the bytes and the row are both durable. An
|
|
157
|
+
// unreachable queue must not turn that into a failed upload, and the extraction sweep exists precisely
|
|
158
|
+
// to pick up what a lost enqueue dropped.
|
|
159
|
+
if (deps.requestExtraction !== undefined) {
|
|
160
|
+
try {
|
|
161
|
+
await deps.requestExtraction({
|
|
162
|
+
tenantId: context.tenantId,
|
|
163
|
+
fileId: id,
|
|
164
|
+
mediaType: input.mediaType,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
(deps.log ?? (() => { }))("extraction request failed after upload", { id, error });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// The size as *written*, not as declared. They differ when a client lies, and the record should say
|
|
172
|
+
// what is actually there.
|
|
173
|
+
return { ...pending, state: "stored", byteSize: stored.byteSize, checksum: stored.checksum };
|
|
174
|
+
},
|
|
175
|
+
/** AC-3: only through the owning conversation, and a foreign file is `not_found`, never `forbidden`. */
|
|
176
|
+
async get(context, id) {
|
|
177
|
+
const file = await deps.metadata.get({ tenantId: context.tenantId, id });
|
|
178
|
+
// Indistinguishable from absent, so the endpoint cannot be used to probe which ids exist — the same
|
|
179
|
+
// rule ShareFlow's `getPost` states: "the two must be indistinguishable, or the endpoint confirms the
|
|
180
|
+
// existence of other tenants' ids."
|
|
181
|
+
if (file === null || file.deletedAt !== undefined)
|
|
182
|
+
throw refuse("not_found", "no such file");
|
|
183
|
+
if (file.state !== "stored")
|
|
184
|
+
throw refuse("not_found", "that file is not available");
|
|
185
|
+
// The decision is about the *conversation*, so there is no second permission model to keep in step
|
|
186
|
+
// with the first: whoever may read the thread may read what is attached to it.
|
|
187
|
+
const decision = await deps.authorization.can(context, "read", {
|
|
188
|
+
type: "conversation",
|
|
189
|
+
id: file.conversationId,
|
|
190
|
+
});
|
|
191
|
+
// `not_found` again, and the same message: a `forbidden` here would confirm the file exists to
|
|
192
|
+
// exactly the caller who is not allowed to know that.
|
|
193
|
+
if (!decision.allow)
|
|
194
|
+
throw refuse("not_found", "no such file");
|
|
195
|
+
return file;
|
|
196
|
+
},
|
|
197
|
+
/**
|
|
198
|
+
* A short-lived URL, or a stream when the adapter proxies.
|
|
199
|
+
*
|
|
200
|
+
* The expiry is clamped rather than trusted: a caller asking for a day gets fifteen minutes. AC-6 is
|
|
201
|
+
* about what is *reachable*, and a caller's optimism is not a reason to widen it.
|
|
202
|
+
*/
|
|
203
|
+
async signedReadUrl(context, id) {
|
|
204
|
+
const file = await this.get(context, id);
|
|
205
|
+
return deps.content.signedUrl({
|
|
206
|
+
tenantId: context.tenantId,
|
|
207
|
+
contentKey: file.contentKey,
|
|
208
|
+
expiresInSeconds: Math.min(limits.signedUrlSeconds, MAX_SIGNED_URL_SECONDS),
|
|
209
|
+
});
|
|
210
|
+
},
|
|
211
|
+
/** The proxied read, for an adapter that cannot sign — and the fallback when one can. */
|
|
212
|
+
async read(context, id) {
|
|
213
|
+
const file = await this.get(context, id);
|
|
214
|
+
const bytes = await deps.content.readFile({
|
|
215
|
+
tenantId: context.tenantId,
|
|
216
|
+
contentKey: file.contentKey,
|
|
217
|
+
});
|
|
218
|
+
if (bytes === null)
|
|
219
|
+
// Metadata says `stored` and the bytes are gone: the other orphan direction, seen from the read
|
|
220
|
+
// path. Reported as what it is rather than as an empty file, because an empty file is something a
|
|
221
|
+
// caller might reasonably use.
|
|
222
|
+
throw refuse("not_found", "that file's contents are missing");
|
|
223
|
+
return bytes;
|
|
224
|
+
},
|
|
225
|
+
/**
|
|
226
|
+
* The listing is authorised on the conversation before it runs, not filtered afterwards.
|
|
227
|
+
*
|
|
228
|
+
* Filtering results would mean the query ran, which for an unentitled caller is a query whose *shape*
|
|
229
|
+
* — a page count, a cursor, a timing difference — still answers "does this conversation have files".
|
|
230
|
+
*/
|
|
231
|
+
async listForConversation(context, input) {
|
|
232
|
+
const decision = await deps.authorization.can(context, "read", {
|
|
233
|
+
type: "conversation",
|
|
234
|
+
id: input.conversationId,
|
|
235
|
+
});
|
|
236
|
+
if (!decision.allow)
|
|
237
|
+
throw refuse("not_found", "no such conversation");
|
|
238
|
+
return deps.metadata.listByConversation({ tenantId: context.tenantId, ...input });
|
|
239
|
+
},
|
|
240
|
+
/**
|
|
241
|
+
* AC-4: deleting a conversation removes the metadata and schedules the bytes.
|
|
242
|
+
*
|
|
243
|
+
* Two steps, and they cannot be one: object storage does not join a database transaction. So the
|
|
244
|
+
* metadata moves to `deleting` — gone from the user's view — and `sweepDeletions` removes the bytes
|
|
245
|
+
* afterwards. The intermediate state is named rather than pretended away.
|
|
246
|
+
*/
|
|
247
|
+
async deleteConversationFiles(context, conversationId) {
|
|
248
|
+
return deps.metadata.scheduleConversationDeletion({
|
|
249
|
+
tenantId: context.tenantId,
|
|
250
|
+
conversationId,
|
|
251
|
+
at: clock(),
|
|
252
|
+
});
|
|
253
|
+
},
|
|
254
|
+
/**
|
|
255
|
+
* Remove the bytes of files marked `deleting`.
|
|
256
|
+
*
|
|
257
|
+
* Bytes first, then the state — the opposite order from upload, and for the mirrored reason. Marking
|
|
258
|
+
* `deleted` first means a crash leaves an object nothing references and nothing will look for again.
|
|
259
|
+
* Deleting bytes first means a crash leaves the row in `deleting`, and the next sweep retries it;
|
|
260
|
+
* `deleteFile` is idempotent so the retry costs nothing.
|
|
261
|
+
*/
|
|
262
|
+
async sweepDeletions(context, input) {
|
|
263
|
+
const page = await deps.metadata.listByState({
|
|
264
|
+
tenantId: context.tenantId,
|
|
265
|
+
state: "deleting",
|
|
266
|
+
olderThan: input.olderThan,
|
|
267
|
+
limit: input.limit,
|
|
268
|
+
});
|
|
269
|
+
let deleted = 0;
|
|
270
|
+
let failed = 0;
|
|
271
|
+
for (const file of page.items) {
|
|
272
|
+
try {
|
|
273
|
+
await deps.content.deleteFile({ tenantId: context.tenantId, contentKey: file.contentKey });
|
|
274
|
+
await deps.metadata.transition({
|
|
275
|
+
tenantId: context.tenantId,
|
|
276
|
+
id: file.id,
|
|
277
|
+
from: "deleting",
|
|
278
|
+
to: "deleted",
|
|
279
|
+
at: clock(),
|
|
280
|
+
});
|
|
281
|
+
deleted += 1;
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
// Counted, not thrown. One unreachable object must not stop the sweep — the row stays `deleting`
|
|
285
|
+
// and the next run retries it, which is the whole reason bytes are deleted before the state moves.
|
|
286
|
+
failed += 1;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return { deleted, failed };
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
};
|
|
293
|
+
/**
|
|
294
|
+
* AC-5: detect and report orphans.
|
|
295
|
+
*
|
|
296
|
+
* **Reports, never deletes**, and that is the AC's own wording rather than caution for its own sake: a
|
|
297
|
+
* reconciliation job that deletes is a job that can delete a file whose metadata write is merely slow. The
|
|
298
|
+
* threshold makes that unlikely; deleting on the strength of "unlikely" is how data goes missing.
|
|
299
|
+
*
|
|
300
|
+
* Both directions are covered, and only one of them is visible to the metadata store — bytes with no
|
|
301
|
+
* metadata can only be found by listing the objects, which is why `FileContentStore.listObjects` exists.
|
|
302
|
+
*/
|
|
303
|
+
export const reconcileFiles = async (context, deps, input) => {
|
|
304
|
+
const scope = { tenantId: context.tenantId };
|
|
305
|
+
const [pending, deleting] = await Promise.all([
|
|
306
|
+
deps.metadata.listByState({ ...scope, state: "pending", olderThan: input.olderThan, limit: input.limit }),
|
|
307
|
+
deps.metadata.listByState({ ...scope, state: "deleting", olderThan: input.olderThan, limit: input.limit }),
|
|
308
|
+
]);
|
|
309
|
+
const objects = await deps.content.listObjects({ ...scope, limit: input.limit });
|
|
310
|
+
const known = new Set();
|
|
311
|
+
const missingContent = [];
|
|
312
|
+
const storedKeys = new Set(objects.items.map((o) => o.contentKey));
|
|
313
|
+
// Every state, not only the live ones: an object referenced by a `deleting` row is not an orphan, it is
|
|
314
|
+
// a sweep that has not run yet. Reporting it as orphaned would send someone to delete something already
|
|
315
|
+
// scheduled for deletion, and the report would never come clean.
|
|
316
|
+
for (const state of ["pending", "stored", "deleting"]) {
|
|
317
|
+
const page = await deps.metadata.listByState({
|
|
318
|
+
...scope,
|
|
319
|
+
state,
|
|
320
|
+
// Everything, not only the old: this pass is building the set of referenced keys, and a recent file
|
|
321
|
+
// is still a reference.
|
|
322
|
+
olderThan: new Date(8_640_000_000_000).toISOString(),
|
|
323
|
+
limit: input.limit,
|
|
324
|
+
});
|
|
325
|
+
for (const file of page.items) {
|
|
326
|
+
known.add(file.contentKey);
|
|
327
|
+
if (state === "stored" && !storedKeys.has(file.contentKey))
|
|
328
|
+
missingContent.push(file.id);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return {
|
|
332
|
+
stuckPending: pending.items.map((f) => f.id),
|
|
333
|
+
stuckDeleting: deleting.items.map((f) => f.id),
|
|
334
|
+
orphanedObjects: objects.items.filter((o) => !known.has(o.contentKey)).map((o) => o.contentKey),
|
|
335
|
+
missingContent,
|
|
336
|
+
};
|
|
337
|
+
};
|
|
338
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `read_attachment` and `list_attachments` — the explicit, bounded read step (#130, AC-3).
|
|
3
|
+
*
|
|
4
|
+
* Attaching a file puts a *reference* in context. Nothing loads its content, and that is the whole design:
|
|
5
|
+
* "referenced, not injected" only means something if bringing content in is a separate, deliberate act.
|
|
6
|
+
* These two tools are that act, and both are bounded on purpose.
|
|
7
|
+
*
|
|
8
|
+
* **The bound is the point, not a nicety.** A read step with no ceiling reintroduces exactly what the
|
|
9
|
+
* reference part avoided — one call and a 100 MB file is in the transcript, permanently, because a tool
|
|
10
|
+
* result is a message part. So a read returns at most `MAX_READ_BYTES`, says so, and reports the offset to
|
|
11
|
+
* continue from. The model can page; it cannot flood.
|
|
12
|
+
*
|
|
13
|
+
* **Reads go through `FileService`, not the stores.** That is where AC-3's entitlement check lives, and
|
|
14
|
+
* duplicating it here would be a second permission model to keep in step with the first. A tool that reached
|
|
15
|
+
* for `FileContentStore` directly would be tenant-scoped but not conversation-scoped, which is the exact hole
|
|
16
|
+
* #129 closed.
|
|
17
|
+
*/
|
|
18
|
+
import type { Tool } from "../tools/index.js";
|
|
19
|
+
import type { FileService } from "./index.js";
|
|
20
|
+
/**
|
|
21
|
+
* The most one call returns.
|
|
22
|
+
*
|
|
23
|
+
* 32 KiB is roughly 8k tokens — a large but survivable share of a context window, and small enough that a
|
|
24
|
+
* model paging a long document has to decide it is worth the budget rather than getting there by accident.
|
|
25
|
+
*/
|
|
26
|
+
export declare const MAX_READ_BYTES: number;
|
|
27
|
+
/**
|
|
28
|
+
* Media types this tool will decode as text.
|
|
29
|
+
*
|
|
30
|
+
* An exact list, for the same reason `DEFAULT_UPLOAD_LIMITS` uses one: `text/*` is how a `text/html` payload
|
|
31
|
+
* with a script becomes "just text". A PDF or an image is refused *with the reason and the alternative*, so
|
|
32
|
+
* the model redirects instead of retrying — a refusal that does not say what to do next produces a loop.
|
|
33
|
+
*/
|
|
34
|
+
export declare const READABLE_AS_TEXT: readonly string[];
|
|
35
|
+
export type ReadAttachmentInput = {
|
|
36
|
+
readonly fileId: string;
|
|
37
|
+
readonly offset?: number;
|
|
38
|
+
readonly maxBytes?: number;
|
|
39
|
+
};
|
|
40
|
+
export type ReadAttachmentOutput = {
|
|
41
|
+
readonly fileId: string;
|
|
42
|
+
readonly filename: string;
|
|
43
|
+
readonly mediaType: string;
|
|
44
|
+
readonly text: string;
|
|
45
|
+
readonly byteOffset: number;
|
|
46
|
+
readonly bytesReturned: number;
|
|
47
|
+
readonly totalBytes: number;
|
|
48
|
+
/** True when there is more to read. The model pages from `nextOffset` rather than guessing. */
|
|
49
|
+
readonly truncated: boolean;
|
|
50
|
+
readonly nextOffset?: number;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Read a bounded window of an attachment.
|
|
54
|
+
*
|
|
55
|
+
* The window is taken by consuming the stream and discarding what precedes the offset, rather than by
|
|
56
|
+
* assembling the whole file and slicing it: a 100 MB file must not be held in memory to read its first
|
|
57
|
+
* kilobyte, which is the mistake that makes a "bounded" read unbounded in the only dimension that matters.
|
|
58
|
+
*/
|
|
59
|
+
export declare const createReadAttachmentTool: (deps: {
|
|
60
|
+
readonly files: FileService;
|
|
61
|
+
}) => Tool;
|
|
62
|
+
export type ListAttachmentsOutput = {
|
|
63
|
+
readonly attachments: readonly {
|
|
64
|
+
readonly fileId: string;
|
|
65
|
+
readonly filename: string;
|
|
66
|
+
readonly mediaType: string;
|
|
67
|
+
readonly size: string;
|
|
68
|
+
}[];
|
|
69
|
+
readonly nextCursor?: string;
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* The rest of the list, when the context section capped it.
|
|
73
|
+
*
|
|
74
|
+
* The section names this tool rather than silently truncating, because a list that ends without saying so is
|
|
75
|
+
* a model concluding a file is not there.
|
|
76
|
+
*/
|
|
77
|
+
export declare const createListAttachmentsTool: (deps: {
|
|
78
|
+
readonly files: FileService;
|
|
79
|
+
readonly conversationId: string;
|
|
80
|
+
}) => Tool;
|
|
81
|
+
//# sourceMappingURL=read-tool.d.ts.map
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `read_attachment` and `list_attachments` — the explicit, bounded read step (#130, AC-3).
|
|
3
|
+
*
|
|
4
|
+
* Attaching a file puts a *reference* in context. Nothing loads its content, and that is the whole design:
|
|
5
|
+
* "referenced, not injected" only means something if bringing content in is a separate, deliberate act.
|
|
6
|
+
* These two tools are that act, and both are bounded on purpose.
|
|
7
|
+
*
|
|
8
|
+
* **The bound is the point, not a nicety.** A read step with no ceiling reintroduces exactly what the
|
|
9
|
+
* reference part avoided — one call and a 100 MB file is in the transcript, permanently, because a tool
|
|
10
|
+
* result is a message part. So a read returns at most `MAX_READ_BYTES`, says so, and reports the offset to
|
|
11
|
+
* continue from. The model can page; it cannot flood.
|
|
12
|
+
*
|
|
13
|
+
* **Reads go through `FileService`, not the stores.** That is where AC-3's entitlement check lives, and
|
|
14
|
+
* duplicating it here would be a second permission model to keep in step with the first. A tool that reached
|
|
15
|
+
* for `FileContentStore` directly would be tenant-scoped but not conversation-scoped, which is the exact hole
|
|
16
|
+
* #129 closed.
|
|
17
|
+
*/
|
|
18
|
+
import { AgentPlatformError } from "../core/errors.js";
|
|
19
|
+
import { asId } from "../core/ids.js";
|
|
20
|
+
import { defineTool } from "../tools/define.js";
|
|
21
|
+
import { MAX_LISTED_ATTACHMENTS, humanSize, truncateFilename } from "./context.js";
|
|
22
|
+
/**
|
|
23
|
+
* The most one call returns.
|
|
24
|
+
*
|
|
25
|
+
* 32 KiB is roughly 8k tokens — a large but survivable share of a context window, and small enough that a
|
|
26
|
+
* model paging a long document has to decide it is worth the budget rather than getting there by accident.
|
|
27
|
+
*/
|
|
28
|
+
export const MAX_READ_BYTES = 32 * 1024;
|
|
29
|
+
/**
|
|
30
|
+
* Media types this tool will decode as text.
|
|
31
|
+
*
|
|
32
|
+
* An exact list, for the same reason `DEFAULT_UPLOAD_LIMITS` uses one: `text/*` is how a `text/html` payload
|
|
33
|
+
* with a script becomes "just text". A PDF or an image is refused *with the reason and the alternative*, so
|
|
34
|
+
* the model redirects instead of retrying — a refusal that does not say what to do next produces a loop.
|
|
35
|
+
*/
|
|
36
|
+
export const READABLE_AS_TEXT = [
|
|
37
|
+
"text/plain",
|
|
38
|
+
"text/csv",
|
|
39
|
+
"text/markdown",
|
|
40
|
+
"application/json",
|
|
41
|
+
];
|
|
42
|
+
const refuse = (code, message) => new AgentPlatformError({ code, message, retryable: false });
|
|
43
|
+
/**
|
|
44
|
+
* Read a bounded window of an attachment.
|
|
45
|
+
*
|
|
46
|
+
* The window is taken by consuming the stream and discarding what precedes the offset, rather than by
|
|
47
|
+
* assembling the whole file and slicing it: a 100 MB file must not be held in memory to read its first
|
|
48
|
+
* kilobyte, which is the mistake that makes a "bounded" read unbounded in the only dimension that matters.
|
|
49
|
+
*/
|
|
50
|
+
export const createReadAttachmentTool = (deps) => defineTool({
|
|
51
|
+
name: "read_attachment",
|
|
52
|
+
label: "Read attachment",
|
|
53
|
+
description: `Read up to ${MAX_READ_BYTES} bytes of a text attachment, from an optional byte offset. ` +
|
|
54
|
+
"Attachments are referenced in context but never loaded; this is the only way to see their contents.",
|
|
55
|
+
category: "files",
|
|
56
|
+
effect: "read",
|
|
57
|
+
inputSchema: {
|
|
58
|
+
type: "object",
|
|
59
|
+
required: ["fileId"],
|
|
60
|
+
additionalProperties: false,
|
|
61
|
+
properties: {
|
|
62
|
+
fileId: { type: "string", description: "The id from the attachment reference, without the `file:` prefix." },
|
|
63
|
+
offset: { type: "integer", minimum: 0, description: "Byte offset to start from. Defaults to 0." },
|
|
64
|
+
maxBytes: {
|
|
65
|
+
type: "integer",
|
|
66
|
+
minimum: 1,
|
|
67
|
+
maximum: MAX_READ_BYTES,
|
|
68
|
+
description: `At most ${MAX_READ_BYTES}. A larger value is clamped, not refused.`,
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
async execute(input, context) {
|
|
73
|
+
const id = asId(input.fileId.replace(/^file:/, ""));
|
|
74
|
+
// Entitlement, existence and state all come from the service, so a tool cannot see a file a user
|
|
75
|
+
// cannot.
|
|
76
|
+
const file = await deps.files.get(context, id);
|
|
77
|
+
if (!READABLE_AS_TEXT.includes(file.mediaType.split(";")[0]?.trim().toLowerCase() ?? ""))
|
|
78
|
+
throw refuse("invalid_input", `${file.filename} is ${file.mediaType}, which this tool cannot decode as text. ` +
|
|
79
|
+
"Use a document-extraction tool for that type.");
|
|
80
|
+
const offset = Math.max(0, Math.floor(input.offset ?? 0));
|
|
81
|
+
// Clamped rather than refused: a model asking for more than the ceiling has made a reasonable request
|
|
82
|
+
// that the platform answers with less, and refusing it would just be answered by a retry.
|
|
83
|
+
const limit = Math.min(Math.max(1, Math.floor(input.maxBytes ?? MAX_READ_BYTES)), MAX_READ_BYTES);
|
|
84
|
+
const collected = [];
|
|
85
|
+
let seen = 0;
|
|
86
|
+
let taken = 0;
|
|
87
|
+
for await (const chunk of await deps.files.read(context, id)) {
|
|
88
|
+
// Past the window: stop pulling. The generator is abandoned here, which cancels the underlying read
|
|
89
|
+
// rather than draining the rest of a large object for bytes nobody asked for.
|
|
90
|
+
if (taken >= limit)
|
|
91
|
+
break;
|
|
92
|
+
const chunkStart = seen;
|
|
93
|
+
seen += chunk.byteLength;
|
|
94
|
+
if (seen <= offset)
|
|
95
|
+
continue;
|
|
96
|
+
const from = Math.max(0, offset - chunkStart);
|
|
97
|
+
const slice = chunk.subarray(from, from + (limit - taken));
|
|
98
|
+
collected.push(slice);
|
|
99
|
+
taken += slice.byteLength;
|
|
100
|
+
}
|
|
101
|
+
const joined = new Uint8Array(taken);
|
|
102
|
+
let at = 0;
|
|
103
|
+
for (const slice of collected) {
|
|
104
|
+
joined.set(slice, at);
|
|
105
|
+
at += slice.byteLength;
|
|
106
|
+
}
|
|
107
|
+
const end = offset + taken;
|
|
108
|
+
// `file.byteSize` is the size as *written* (#129), not as declared, so "is there more" is answered
|
|
109
|
+
// against what is actually stored.
|
|
110
|
+
const truncated = end < file.byteSize;
|
|
111
|
+
return {
|
|
112
|
+
fileId: id,
|
|
113
|
+
filename: file.filename,
|
|
114
|
+
mediaType: file.mediaType,
|
|
115
|
+
// `fatal: false` on purpose: a window boundary can land mid-codepoint, and a decode error would turn
|
|
116
|
+
// a legitimate paged read into a failure. The replacement character is the honest answer.
|
|
117
|
+
text: new TextDecoder("utf-8", { fatal: false }).decode(joined),
|
|
118
|
+
byteOffset: offset,
|
|
119
|
+
bytesReturned: taken,
|
|
120
|
+
totalBytes: file.byteSize,
|
|
121
|
+
truncated,
|
|
122
|
+
...(truncated ? { nextOffset: end } : {}),
|
|
123
|
+
};
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
/**
|
|
127
|
+
* The rest of the list, when the context section capped it.
|
|
128
|
+
*
|
|
129
|
+
* The section names this tool rather than silently truncating, because a list that ends without saying so is
|
|
130
|
+
* a model concluding a file is not there.
|
|
131
|
+
*/
|
|
132
|
+
export const createListAttachmentsTool = (deps) => defineTool({
|
|
133
|
+
name: "list_attachments",
|
|
134
|
+
label: "List attachments",
|
|
135
|
+
description: "List this conversation's attachments as references. Contents are not returned.",
|
|
136
|
+
category: "files",
|
|
137
|
+
effect: "read",
|
|
138
|
+
inputSchema: {
|
|
139
|
+
type: "object",
|
|
140
|
+
additionalProperties: false,
|
|
141
|
+
properties: { cursor: { type: "string", description: "From a previous call's `nextCursor`." } },
|
|
142
|
+
},
|
|
143
|
+
async execute(input, context) {
|
|
144
|
+
const page = await deps.files.listForConversation(context, {
|
|
145
|
+
conversationId: asId(deps.conversationId),
|
|
146
|
+
limit: MAX_LISTED_ATTACHMENTS,
|
|
147
|
+
...(input.cursor === undefined ? {} : { cursor: input.cursor }),
|
|
148
|
+
});
|
|
149
|
+
return {
|
|
150
|
+
// The same fields the reference line carries, and no others. A listing that returned more than the
|
|
151
|
+
// reference does would be a second, richer view of an attachment for no reason — and the place a
|
|
152
|
+
// content field would eventually be added.
|
|
153
|
+
attachments: page.items.map((file) => ({
|
|
154
|
+
fileId: file.id,
|
|
155
|
+
filename: truncateFilename(file.filename),
|
|
156
|
+
mediaType: file.mediaType,
|
|
157
|
+
size: humanSize(file.byteSize),
|
|
158
|
+
})),
|
|
159
|
+
...(page.nextCursor === undefined ? {} : { nextCursor: page.nextCursor }),
|
|
160
|
+
};
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
//# sourceMappingURL=read-tool.js.map
|