feat(ai): large tool output handling + navigation tools (#21982)
## Summary
Large tool outputs (e.g. a workflow run that serializes to ~70k tokens)
blow the chat context budget and force per-tool "raw" variants. This PR
handles oversized outputs generically in one place:
1. **Producer:** when a tool result exceeds a byte budget, it is spilled
to a `FileFolder.AgentChat` file and replaced with a compact `{ spilled,
outputRef, shape, hint }` envelope.
2. **Consumer:** two bounded, in-server navigation tools —
`extract_json_path` and `search_output` — let the model dig into the
spilled file by `fileId` without spinning up `code_interpreter`.
Together they add a fast, auditable middle tier between "truncated
inline preview" and "full code_interpreter relay," and enable an
enterprise "restricted" mode (spill + navigation, no sandbox).
## Data flow
```mermaid
flowchart TD
exec["resolveAndExecute / hydrateToolSet closure"] --> compact[compactToolOutput]
compact --> enabled{"spillLargeOutput enabled? (chat only)"}
enabled -->|no| inlineRaw["inline raw (MCP, workflow, sandbox bridge)"]
enabled -->|yes| size{"bytes > MAX_INLINE_TOOL_OUTPUT_BYTES?"}
size -->|no| inline["inline result"]
size -->|yes| skeleton["jsonShapeSkeleton + largeOutputHint"]
skeleton --> write["writeFile(AgentChat)"]
write --> envelope["return { spilled, outputRef, shape, hint }"]
envelope --> model[Model]
model --> nav["extract_json_path / search_output / code_interpreter (by fileId)"]
```
## Part 1 — Navigation tools (consumer)
- `extract_json_path`: extracts a sub-tree from a spilled JSON file by a
JSONPath-lite expression (dot/bracket access, array slicing,
single-level wildcard), with `maxItems`/`maxDepth` bounding. No filters
or recursive descent — those belong to `code_interpreter`.
- `search_output`: grep-like line search with context lines and
stateless `offset` pagination (`{ matches, totalMatches, hasMore }`).
- Both read from `FileFolder.AgentChat` by `fileId`, enforce their own
output byte cap, and are registered in `ActionToolProvider` (always
available; read-only).
## Part 2 — Spill producer
- Spilling slots in right after the existing `compactToolOutput` step at
the two seams in `ToolRegistryService` (`resolveAndExecute` and the
`hydrateToolSet` execute closure).
- `ToolOutputSpillService.spillIfTooLarge()` measures
`Buffer.byteLength`; over `MAX_INLINE_TOOL_OUTPUT_BYTES` (16 KB ≈ 4k
tokens) it writes the full payload and returns the envelope. Spill
failures never block the call (inline + warning).
- `jsonShapeSkeleton` computes a bounded structural map (depth 4, arrays
as `"array[N] of <type>"`, id-keyed maps collapsed, long leaves as size
markers, hard-capped at 1024 bytes) so the model knows the key paths in
one pass.
- Optional per-tool `largeOutputHint` (on the `Tool` type, threaded via
the descriptor) is used as the hint when present, else a generic hint.
The `shape` is always computed generically.
## Surfaces
Spilling is an opt-in flag (`spillLargeOutput`) mirroring
`compactOutput`:
| Surface | `spillLargeOutput` | Behavior |
| --- | --- | --- |
| AI chat / agent | `true` (in `chat-execution.service.ts`) | Spill on;
nav tools + `code_interpreter` in catalog |
| External MCP clients | unset | Raw output |
| Workflow agents | unset | Raw output |
| `code_interpreter` sandbox bridge | unset (it's an MCP call) | Raw
output |
The sandbox bridge inherits "no spill" for free via the MCP path — no
header sniffing, no `ToolContext.source` field.
## Design constraints (anti-micro-OS)
Exactly two navigation tools, no composition/piping, read-only, bounded
output. The boundary is: expressible as a single path lookup or text
search → nav tool; aggregation/correlation/transform →
`code_interpreter`.
## Notes / deviations from the plan
- `jsonShapeSkeleton` and `ToolOutputSpillService` live under the `tool`
module (not `tool-provider/output-transforms`) to avoid a `tool →
tool-provider` import cycle.
- Spill files use `{ isTemporaryFile: false, toDelete: false }` (same as
`code_interpreter`); `isTemporaryFile` here means files-field promotion,
not a TTL.
## Test plan
- [x] `extract-json-path` + `search-output` util unit tests (23 cases)
- [x] `jsonShapeSkeleton` unit tests (6) and `ToolOutputSpillService`
unit tests (4)
- [x] oxlint + oxfmt clean on changed files; `twenty-server` typecheck
clean (pre-existing unrelated errors aside)
- [ ] Manual: trigger an oversized tool result in chat, confirm the
envelope is returned and `extract_json_path` / `search_output` read the
spilled file by `fileId`
## Why no automated e2e
Spilling is chat-only and the chat path runs a live model, so the
black-box MCP integration harness can't deterministically trigger a
spill (MCP intentionally doesn't spill). The seam is small, explicit
flag-threading mirrored on `compactOutput`, covered by the unit suites.
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21982?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
@@ -152,6 +152,7 @@
|
||||
"pluralize": "8.0.0",
|
||||
"postal-mime": "^2.6.1",
|
||||
"psl": "^1.9.0",
|
||||
"re2": "^1.25.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"redis": "^4.7.0",
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { OUTPUT_NAVIGATION_TOOL_NAMES } from 'src/engine/core-modules/tool/tools/output-navigation-tool/constants/output-navigation-tool-names.constant';
|
||||
|
||||
export const MCP_EXCLUDED_TOOL_NAMES = new Set([
|
||||
'code_interpreter',
|
||||
'http_request',
|
||||
...OUTPUT_NAVIGATION_TOOL_NAMES,
|
||||
]);
|
||||
|
||||
+6
@@ -9,4 +9,10 @@ export type ToolRetrievalOptions = {
|
||||
// before returning. Chat enables this to reduce token usage in the
|
||||
// conversation context; MCP and workflow agents leave raw output intact.
|
||||
compactOutput?: boolean;
|
||||
// Spill oversized dispatch results to a file and return a compact
|
||||
// { spilled, outputRef, shape, hint } envelope instead of the raw payload.
|
||||
// Same axis as compactOutput: chat enables it (and has the navigation tools
|
||||
// to read the file); MCP, workflow agents, and the code_interpreter sandbox
|
||||
// bridge leave output raw.
|
||||
spillLargeOutput?: boolean;
|
||||
};
|
||||
|
||||
+22
@@ -17,6 +17,8 @@ import { DraftEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/dr
|
||||
import { SendEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/send-email-tool';
|
||||
import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool';
|
||||
import { NavigateAppTool } from 'src/engine/core-modules/tool/tools/navigate-tool/navigate-app-tool';
|
||||
import { ExtractJsonPathsTool } from 'src/engine/core-modules/tool/tools/output-navigation-tool/extract-json-paths-tool';
|
||||
import { SearchOutputTool } from 'src/engine/core-modules/tool/tools/output-navigation-tool/search-output-tool';
|
||||
import { SearchHelpCenterTool } from 'src/engine/core-modules/tool/tools/search-help-center-tool/search-help-center-tool';
|
||||
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
|
||||
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
|
||||
@@ -35,6 +37,8 @@ export class ActionToolProvider implements ToolProvider {
|
||||
private readonly searchHelpCenterTool: SearchHelpCenterTool,
|
||||
private readonly codeInterpreterTool: CodeInterpreterTool,
|
||||
private readonly navigateAppTool: NavigateAppTool,
|
||||
private readonly extractJsonPathsTool: ExtractJsonPathsTool,
|
||||
private readonly searchOutputTool: SearchOutputTool,
|
||||
private readonly codeInterpreterService: CodeInterpreterService,
|
||||
private readonly permissionsService: PermissionsService,
|
||||
) {
|
||||
@@ -45,6 +49,8 @@ export class ActionToolProvider implements ToolProvider {
|
||||
['search_help_center', this.searchHelpCenterTool],
|
||||
['code_interpreter', this.codeInterpreterTool],
|
||||
['navigate_app', this.navigateAppTool],
|
||||
['extract_json_paths', this.extractJsonPathsTool],
|
||||
['search_output', this.searchOutputTool],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -106,6 +112,22 @@ export class ActionToolProvider implements ToolProvider {
|
||||
),
|
||||
);
|
||||
|
||||
descriptors.push(
|
||||
this.buildDescriptor(
|
||||
'extract_json_paths',
|
||||
this.extractJsonPathsTool,
|
||||
includeSchemas,
|
||||
),
|
||||
);
|
||||
|
||||
descriptors.push(
|
||||
this.buildDescriptor(
|
||||
'search_output',
|
||||
this.searchOutputTool,
|
||||
includeSchemas,
|
||||
),
|
||||
);
|
||||
|
||||
const hasCodeInterpreterPermission =
|
||||
this.codeInterpreterService.isEnabled() &&
|
||||
(await this.permissionsService.hasToolPermission(
|
||||
|
||||
+27
-3
@@ -9,6 +9,7 @@ import { type ToolRetrievalOptions } from 'src/engine/core-modules/tool-provider
|
||||
import { TOOL_PROVIDERS } from 'src/engine/core-modules/tool-provider/constants/tool-providers.token';
|
||||
import { compactToolOutput } from 'src/engine/core-modules/tool-provider/output-transforms/compact-tool-output.util';
|
||||
import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
|
||||
import { ToolOutputSpillService } from 'src/engine/core-modules/tool/services/tool-output-spill.service';
|
||||
import { type LearnToolsAspect } from 'src/engine/core-modules/tool-provider/tools/learn-tools.tool';
|
||||
import { type ToolContext } from 'src/engine/core-modules/tool-provider/types/tool-context.type';
|
||||
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
|
||||
@@ -30,6 +31,7 @@ export class ToolRegistryService {
|
||||
@Inject(TOOL_PROVIDERS)
|
||||
private readonly providers: ToolProvider[],
|
||||
private readonly toolExecutorService: ToolExecutorService,
|
||||
private readonly toolOutputSpillService: ToolOutputSpillService,
|
||||
) {}
|
||||
|
||||
async getCatalog(context: ToolProviderContext): Promise<ToolIndexEntry[]> {
|
||||
@@ -110,11 +112,13 @@ export class ToolRegistryService {
|
||||
wrapWithErrorContext?: boolean;
|
||||
includeLoadingMessage?: boolean;
|
||||
compactOutput?: boolean;
|
||||
spillLargeOutput?: boolean;
|
||||
},
|
||||
): ToolSet {
|
||||
const toolSet: ToolSet = {};
|
||||
const includeLoadingMessage = options?.includeLoadingMessage ?? true;
|
||||
const compactOutput = options?.compactOutput ?? false;
|
||||
const spillLargeOutput = options?.spillLargeOutput ?? false;
|
||||
|
||||
for (const descriptor of descriptors) {
|
||||
const baseSchema = descriptor.inputSchema as Record<string, unknown>;
|
||||
@@ -135,9 +139,17 @@ export class ToolRegistryService {
|
||||
context,
|
||||
);
|
||||
|
||||
return compactOutput
|
||||
const compacted = compactOutput
|
||||
? (compactToolOutput(result) as ToolOutput)
|
||||
: result;
|
||||
|
||||
return spillLargeOutput
|
||||
? this.toolOutputSpillService.spillIfTooLarge(
|
||||
compacted,
|
||||
{ workspaceId: context.workspaceId },
|
||||
{ toolName: descriptor.name },
|
||||
)
|
||||
: compacted;
|
||||
};
|
||||
|
||||
toolSet[descriptor.name] = {
|
||||
@@ -173,6 +185,7 @@ export class ToolRegistryService {
|
||||
options?: {
|
||||
includeLoadingMessage?: boolean;
|
||||
compactOutput?: boolean;
|
||||
spillLargeOutput?: boolean;
|
||||
},
|
||||
): Promise<ToolSet> {
|
||||
const fullContext = this.buildContextFromToolContext(context);
|
||||
@@ -197,6 +210,7 @@ export class ToolRegistryService {
|
||||
return this.hydrateToolSet(descriptors, fullContext, {
|
||||
includeLoadingMessage: options?.includeLoadingMessage,
|
||||
compactOutput: options?.compactOutput,
|
||||
spillLargeOutput: options?.spillLargeOutput,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -271,7 +285,7 @@ export class ToolRegistryService {
|
||||
toolName: string,
|
||||
args: Record<string, unknown> | undefined,
|
||||
context: ToolContext,
|
||||
options?: { compactOutput?: boolean },
|
||||
options?: { compactOutput?: boolean; spillLargeOutput?: boolean },
|
||||
): Promise<ToolOutput> {
|
||||
try {
|
||||
const fullContext = this.buildContextFromToolContext(context);
|
||||
@@ -302,9 +316,17 @@ export class ToolRegistryService {
|
||||
fullContext,
|
||||
);
|
||||
|
||||
return options?.compactOutput
|
||||
const compacted = options?.compactOutput
|
||||
? (compactToolOutput(result) as ToolOutput)
|
||||
: result;
|
||||
|
||||
return options?.spillLargeOutput
|
||||
? this.toolOutputSpillService.spillIfTooLarge(
|
||||
compacted,
|
||||
{ workspaceId: fullContext.workspaceId },
|
||||
{ toolName },
|
||||
)
|
||||
: compacted;
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
@@ -331,6 +353,7 @@ export class ToolRegistryService {
|
||||
wrapWithErrorContext,
|
||||
includeLoadingMessage,
|
||||
compactOutput,
|
||||
spillLargeOutput,
|
||||
} = options;
|
||||
const categorySet = categories ? new Set(categories) : undefined;
|
||||
|
||||
@@ -366,6 +389,7 @@ export class ToolRegistryService {
|
||||
wrapWithErrorContext,
|
||||
includeLoadingMessage,
|
||||
compactOutput,
|
||||
spillLargeOutput,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
|
||||
+2
@@ -48,6 +48,7 @@ export const createExecuteToolTool = (
|
||||
options?: {
|
||||
excludeTools?: Set<string>;
|
||||
compactOutput?: boolean;
|
||||
spillLargeOutput?: boolean;
|
||||
},
|
||||
) => ({
|
||||
description:
|
||||
@@ -66,6 +67,7 @@ export const createExecuteToolTool = (
|
||||
|
||||
return toolRegistry.resolveAndExecute(toolName, args, context, {
|
||||
compactOutput: options?.compactOutput,
|
||||
spillLargeOutput: options?.spillLargeOutput,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
|
||||
import { type ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { type FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { ToolOutputSpillService } from 'src/engine/core-modules/tool/services/tool-output-spill.service';
|
||||
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
|
||||
|
||||
jest.mock('src/engine/core-modules/file-storage/file-storage.service', () => ({
|
||||
FileStorageService: class {},
|
||||
}));
|
||||
jest.mock('src/engine/core-modules/application/application.service', () => ({
|
||||
ApplicationService: class {},
|
||||
}));
|
||||
|
||||
const WORKSPACE_ID = 'workspace-1';
|
||||
|
||||
const buildLargeOutput = (): ToolOutput => ({
|
||||
success: true,
|
||||
message: 'ok',
|
||||
result: {
|
||||
items: Array.from({ length: 2000 }, (_, index) => ({
|
||||
id: `record-${index}`,
|
||||
label: 'a-fairly-long-label-value-to-inflate-the-payload-size',
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
describe('ToolOutputSpillService', () => {
|
||||
const writeFile = jest.fn();
|
||||
const findApplication = jest.fn();
|
||||
|
||||
const service = new ToolOutputSpillService(
|
||||
{ writeFile } as unknown as FileStorageService,
|
||||
{
|
||||
findWorkspaceTwentyStandardAndCustomApplicationOrThrow: findApplication,
|
||||
} as unknown as ApplicationService,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
findApplication.mockResolvedValue({
|
||||
workspaceCustomFlatApplication: { universalIdentifier: 'app-uid' },
|
||||
});
|
||||
// Echo a persisted id that differs from the generated fileId so the test
|
||||
// catches the envelope exposing the wrong id (the nav tools retrieve by
|
||||
// savedFile.id, not by the generated uuid).
|
||||
writeFile.mockImplementation(({ fileId }: { fileId: string }) =>
|
||||
Promise.resolve({ id: `persisted-${fileId}` }),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the output unchanged when under the byte budget', async () => {
|
||||
const output: ToolOutput = {
|
||||
success: true,
|
||||
message: 'ok',
|
||||
result: { id: 'small' },
|
||||
};
|
||||
|
||||
const result = await service.spillIfTooLarge(
|
||||
output,
|
||||
{ workspaceId: WORKSPACE_ID },
|
||||
{ toolName: 'find_many_companies' },
|
||||
);
|
||||
|
||||
expect(result).toBe(output);
|
||||
expect(writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('spills oversized output to an AgentChat file and returns an envelope', async () => {
|
||||
const result = await service.spillIfTooLarge(
|
||||
buildLargeOutput(),
|
||||
{ workspaceId: WORKSPACE_ID },
|
||||
{ toolName: 'find_many_companies' },
|
||||
);
|
||||
|
||||
expect(writeFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
fileFolder: FileFolder.AgentChat,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
applicationUniversalIdentifier: 'app-uid',
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
}),
|
||||
);
|
||||
|
||||
const writeFileArgs = writeFile.mock.calls[0][0] as {
|
||||
fileId: string;
|
||||
resourcePath: string;
|
||||
};
|
||||
|
||||
const envelope = result.result as Record<string, unknown>;
|
||||
const outputRef = envelope.outputRef as Record<string, unknown>;
|
||||
|
||||
expect(envelope.spilled).toBe(true);
|
||||
// The envelope must expose the persisted id (what the nav tools retrieve
|
||||
// by), not the generated fileId used to build the storage resourcePath.
|
||||
expect(outputRef.fileId).toBe(`persisted-${writeFileArgs.fileId}`);
|
||||
expect(writeFileArgs.resourcePath).toBe(
|
||||
`tool-output-spill/${writeFileArgs.fileId}.json`,
|
||||
);
|
||||
expect(outputRef.filename).toMatch(
|
||||
/^tool-output-find_many_companies-.+\.json$/,
|
||||
);
|
||||
expect(envelope.preview).toBeDefined();
|
||||
expect(envelope.hint).toContain('extract_json_paths');
|
||||
});
|
||||
|
||||
it.each(['extract_json_paths', 'search_output'])(
|
||||
'never spills the output of the %s navigation tool',
|
||||
async (toolName) => {
|
||||
const output = buildLargeOutput();
|
||||
|
||||
const result = await service.spillIfTooLarge(
|
||||
output,
|
||||
{ workspaceId: WORKSPACE_ID },
|
||||
{ toolName },
|
||||
);
|
||||
|
||||
expect(result).toBe(output);
|
||||
expect(writeFile).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('falls back to the inline output with a warning when the spill write fails', async () => {
|
||||
writeFile.mockRejectedValue(new Error('storage down'));
|
||||
|
||||
const output = buildLargeOutput();
|
||||
|
||||
const result = await service.spillIfTooLarge(
|
||||
output,
|
||||
{ workspaceId: WORKSPACE_ID },
|
||||
{ toolName: 'find_many_companies' },
|
||||
);
|
||||
|
||||
expect((result.result as Record<string, unknown>).items).toBeDefined();
|
||||
expect(result.warnings).toEqual([
|
||||
'Large output spill failed; the full output is returned inline.',
|
||||
]);
|
||||
});
|
||||
});
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { isObject } from 'class-validator';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { MAX_INLINE_TOOL_OUTPUT_BYTES } from 'src/engine/core-modules/tool/tools/output-navigation-tool/constants/max-inline-tool-output-bytes.constant';
|
||||
import { OUTPUT_NAVIGATION_TOOL_NAMES } from 'src/engine/core-modules/tool/tools/output-navigation-tool/constants/output-navigation-tool-names.constant';
|
||||
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
|
||||
import { formatBytes } from 'src/engine/core-modules/tool/utils/format-bytes.util';
|
||||
import { jsonPreview } from 'src/engine/core-modules/tool/utils/json-preview.util';
|
||||
|
||||
type SpillContext = {
|
||||
workspaceId: string;
|
||||
};
|
||||
|
||||
type SpillOptions = {
|
||||
toolName: string;
|
||||
};
|
||||
|
||||
const OUTPUT_NAVIGATION_TOOL_NAME_SET = new Set<string>(
|
||||
OUTPUT_NAVIGATION_TOOL_NAMES,
|
||||
);
|
||||
|
||||
@Injectable()
|
||||
export class ToolOutputSpillService {
|
||||
private readonly logger = new Logger(ToolOutputSpillService.name);
|
||||
|
||||
constructor(
|
||||
private readonly fileStorageService: FileStorageService,
|
||||
private readonly applicationService: ApplicationService,
|
||||
) {}
|
||||
|
||||
async spillIfTooLarge(
|
||||
output: ToolOutput,
|
||||
{ workspaceId }: SpillContext,
|
||||
options: SpillOptions,
|
||||
): Promise<ToolOutput> {
|
||||
if (!isDefined(output) || !isObject(output)) {
|
||||
return output;
|
||||
}
|
||||
|
||||
if (OUTPUT_NAVIGATION_TOOL_NAME_SET.has(options.toolName)) {
|
||||
return output;
|
||||
}
|
||||
|
||||
let serialized: string | undefined;
|
||||
|
||||
try {
|
||||
serialized = JSON.stringify(output);
|
||||
} catch {
|
||||
return output;
|
||||
}
|
||||
|
||||
if (!isDefined(serialized)) {
|
||||
return output;
|
||||
}
|
||||
|
||||
const sizeBytes = Buffer.byteLength(serialized);
|
||||
|
||||
if (sizeBytes <= MAX_INLINE_TOOL_OUTPUT_BYTES) {
|
||||
return output;
|
||||
}
|
||||
|
||||
try {
|
||||
const preview = jsonPreview(output);
|
||||
const fileId = v4();
|
||||
const filename = `tool-output-${options.toolName}-${fileId}.json`;
|
||||
|
||||
const { workspaceCustomFlatApplication } =
|
||||
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
|
||||
{ workspaceId },
|
||||
);
|
||||
|
||||
const savedFile = await this.fileStorageService.writeFile({
|
||||
sourceFile: Buffer.from(serialized),
|
||||
fileFolder: FileFolder.AgentChat,
|
||||
applicationUniversalIdentifier:
|
||||
workspaceCustomFlatApplication.universalIdentifier,
|
||||
workspaceId,
|
||||
resourcePath: `tool-output-spill/${fileId}.json`,
|
||||
fileId,
|
||||
settings: {
|
||||
isTemporaryFile: false,
|
||||
toDelete: false,
|
||||
},
|
||||
});
|
||||
|
||||
const hint = `Output too large to inline (${formatBytes(sizeBytes)}). "preview" is a truncated sample (first items, all keys); use extract_json_paths (for json objects) or search_output (for text) with this fileId to read the full data, or code_interpreter for analysis.`;
|
||||
|
||||
return {
|
||||
success: output.success,
|
||||
message: output.message,
|
||||
result: {
|
||||
spilled: true,
|
||||
outputRef: {
|
||||
fileId: savedFile.id,
|
||||
filename,
|
||||
},
|
||||
preview,
|
||||
hint,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to spill large output for "${options.toolName}"; returning full output inline.`,
|
||||
error,
|
||||
);
|
||||
|
||||
return {
|
||||
...output,
|
||||
warnings: [
|
||||
...(output.warnings ?? []),
|
||||
'Large output spill failed; the full output is returned inline.',
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,10 @@ import { EmailComposerService } from 'src/engine/core-modules/tool/tools/email-t
|
||||
import { SendEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/send-email-tool';
|
||||
import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool';
|
||||
import { NavigateAppTool } from 'src/engine/core-modules/tool/tools/navigate-tool/navigate-app-tool';
|
||||
import { ExtractJsonPathsTool } from 'src/engine/core-modules/tool/tools/output-navigation-tool/extract-json-paths-tool';
|
||||
import { SearchOutputTool } from 'src/engine/core-modules/tool/tools/output-navigation-tool/search-output-tool';
|
||||
import { SearchHelpCenterTool } from 'src/engine/core-modules/tool/tools/search-help-center-tool/search-help-center-tool';
|
||||
import { ToolOutputSpillService } from 'src/engine/core-modules/tool/services/tool-output-spill.service';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
|
||||
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
|
||||
import { NavigationMenuItemModule } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.module';
|
||||
@@ -45,6 +48,9 @@ import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspac
|
||||
SearchHelpCenterTool,
|
||||
CodeInterpreterTool,
|
||||
NavigateAppTool,
|
||||
ExtractJsonPathsTool,
|
||||
SearchOutputTool,
|
||||
ToolOutputSpillService,
|
||||
provideWorkspaceScopedRepository(FileEntity),
|
||||
],
|
||||
exports: [
|
||||
@@ -55,6 +61,9 @@ import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspac
|
||||
SearchHelpCenterTool,
|
||||
CodeInterpreterTool,
|
||||
NavigateAppTool,
|
||||
ExtractJsonPathsTool,
|
||||
SearchOutputTool,
|
||||
ToolOutputSpillService,
|
||||
],
|
||||
})
|
||||
export class ToolModule {}
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const DEFAULT_EXTRACT_JSON_PATHS_MAX_DEPTH = 5;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const DEFAULT_EXTRACT_JSON_PATHS_MAX_ITEMS = 20;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const DEFAULT_SEARCH_OUTPUT_CONTEXT_LINES = 2;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const DEFAULT_SEARCH_OUTPUT_MAX_MATCHES = 10;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const EXTRACT_JSON_PATHS_MAX_LEAF_STRING_LENGTH = 1000;
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
// Shared budget for inlining a tool result. Above this, outputs are spilled to
|
||||
// a file and navigated via the output
|
||||
// navigation tools. ~16 KB is roughly 4k tokens (~4-5% of a 100k context window).
|
||||
export const MAX_INLINE_TOOL_OUTPUT_BYTES = 16000;
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export const OUTPUT_NAVIGATION_TOOL_NAMES = [
|
||||
'extract_json_paths',
|
||||
'search_output',
|
||||
] as const;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const SEARCH_OUTPUT_MAX_LINE_LENGTH = 500;
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ExtractJsonPathsInputZodSchema = z.object({
|
||||
fileId: z
|
||||
.string()
|
||||
.describe(
|
||||
'ID of the spilled output file to read (the outputRef.fileId returned when a tool result was too large to inline).',
|
||||
),
|
||||
paths: z
|
||||
.array(z.string().regex(/^\$/, 'Each path must start with "$"'))
|
||||
.min(1)
|
||||
.max(20)
|
||||
.describe(
|
||||
'JSONPath-lite expressions to extract in a single pass over the file, e.g. ["$.failedStepLogs["step-id"].entries[0:5]", "$.status"]. Each supports dot notation, bracket keys, array index, slicing [start:end] and a single-level wildcard [*]. Filters and recursive descent are not supported (use code_interpreter for those). All paths share the same maxItems and maxDepth, and each is resolved independently so one failing path does not discard the others.',
|
||||
),
|
||||
maxItems: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.max(200)
|
||||
.optional()
|
||||
.describe('Maximum array items to return per array (default 20).'),
|
||||
maxDepth: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.max(10)
|
||||
.optional()
|
||||
.describe(
|
||||
'Maximum nesting depth before deeper structures are replaced with type markers (default 5).',
|
||||
),
|
||||
});
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
|
||||
import { FileService } from 'src/engine/core-modules/file/services/file.service';
|
||||
import { DEFAULT_EXTRACT_JSON_PATHS_MAX_DEPTH } from 'src/engine/core-modules/tool/tools/output-navigation-tool/constants/default-extract-json-paths-max-depth.constant';
|
||||
import { DEFAULT_EXTRACT_JSON_PATHS_MAX_ITEMS } from 'src/engine/core-modules/tool/tools/output-navigation-tool/constants/default-extract-json-paths-max-items.constant';
|
||||
import { ExtractJsonPathsInputZodSchema } from 'src/engine/core-modules/tool/tools/output-navigation-tool/extract-json-paths-tool.schema';
|
||||
import {
|
||||
extractJsonPath,
|
||||
JsonPathError,
|
||||
} from 'src/engine/core-modules/tool/tools/output-navigation-tool/utils/extract-json-path.util';
|
||||
import { type ToolExecutionContext } from 'src/engine/core-modules/tool/types/tool-execution-context.type';
|
||||
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
|
||||
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
|
||||
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
|
||||
|
||||
type ExtractJsonPathsInput = {
|
||||
fileId: string;
|
||||
paths: string[];
|
||||
maxItems?: number;
|
||||
maxDepth?: number;
|
||||
};
|
||||
|
||||
type ExtractedPathResult =
|
||||
| { path: string; value: unknown }
|
||||
| { path: string; error: string };
|
||||
|
||||
@Injectable()
|
||||
export class ExtractJsonPathsTool implements Tool {
|
||||
private readonly logger = new Logger(ExtractJsonPathsTool.name);
|
||||
|
||||
description =
|
||||
'Extract one or more sub-trees from a large spilled tool output (JSON) by path, without loading the whole file into context. Use the shape/skeleton returned with an outputRef to target paths. Reads and parses the file once, then resolves every path independently (a failing path does not discard the others). Each value is bounded by maxItems and maxDepth.';
|
||||
|
||||
inputSchema = ExtractJsonPathsInputZodSchema;
|
||||
|
||||
constructor(private readonly fileService: FileService) {}
|
||||
|
||||
async execute(
|
||||
parameters: ToolInput,
|
||||
context: ToolExecutionContext,
|
||||
): Promise<ToolOutput> {
|
||||
const { workspaceId } = context;
|
||||
const { fileId, paths, maxItems, maxDepth } =
|
||||
parameters as ExtractJsonPathsInput;
|
||||
|
||||
const effectiveMaxItems = maxItems ?? DEFAULT_EXTRACT_JSON_PATHS_MAX_ITEMS;
|
||||
const effectiveMaxDepth = maxDepth ?? DEFAULT_EXTRACT_JSON_PATHS_MAX_DEPTH;
|
||||
|
||||
let fileContent: { buffer: Buffer; mimeType: string } | null;
|
||||
|
||||
try {
|
||||
fileContent = await this.fileService.getFileContentById({
|
||||
fileId,
|
||||
workspaceId,
|
||||
fileFolder: FileFolder.AgentChat,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to read file ${fileId}`, error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to read output file',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
|
||||
if (fileContent === null) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Output file not found',
|
||||
error: `File "${fileId}" not found or no longer available.`,
|
||||
};
|
||||
}
|
||||
|
||||
let data: unknown;
|
||||
|
||||
try {
|
||||
data = JSON.parse(fileContent.buffer.toString('utf-8'));
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Output file is not valid JSON',
|
||||
error:
|
||||
'The referenced file could not be parsed as JSON. Use search_output for free-text content.',
|
||||
};
|
||||
}
|
||||
|
||||
const results = paths.map((path): ExtractedPathResult => {
|
||||
try {
|
||||
const value = extractJsonPath({
|
||||
data,
|
||||
path,
|
||||
maxItems: effectiveMaxItems,
|
||||
maxDepth: effectiveMaxDepth,
|
||||
});
|
||||
|
||||
return { path, value };
|
||||
} catch (error) {
|
||||
if (error instanceof JsonPathError) {
|
||||
return { path, error: error.message };
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
const resolvedCount = results.filter(
|
||||
(result): result is { path: string; value: unknown } => 'value' in result,
|
||||
).length;
|
||||
|
||||
if (resolvedCount === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Could not resolve any path',
|
||||
result: { results },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Extracted ${resolvedCount}/${paths.length} path(s)`,
|
||||
result: { results },
|
||||
};
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const SearchOutputInputZodSchema = z.object({
|
||||
fileId: z
|
||||
.string()
|
||||
.describe(
|
||||
'ID of the spilled output file to search (the outputRef.fileId returned when a tool result was too large to inline).',
|
||||
),
|
||||
pattern: z
|
||||
.string()
|
||||
.describe(
|
||||
'Text or regular expression to search for. Matched line by line against the indented JSON representation of the file.',
|
||||
),
|
||||
maxMatches: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.max(100)
|
||||
.optional()
|
||||
.describe('Maximum number of matches to return (default 10).'),
|
||||
offset: z
|
||||
.number()
|
||||
.int()
|
||||
.nonnegative()
|
||||
.optional()
|
||||
.describe(
|
||||
'Number of matches to skip before returning results, for stateless pagination (default 0).',
|
||||
),
|
||||
contextLines: z
|
||||
.number()
|
||||
.int()
|
||||
.nonnegative()
|
||||
.max(10)
|
||||
.optional()
|
||||
.describe(
|
||||
'Lines of context to include before and after each match (default 2).',
|
||||
),
|
||||
});
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
|
||||
import { FileService } from 'src/engine/core-modules/file/services/file.service';
|
||||
import { DEFAULT_SEARCH_OUTPUT_CONTEXT_LINES } from 'src/engine/core-modules/tool/tools/output-navigation-tool/constants/default-search-output-context-lines.constant';
|
||||
import { DEFAULT_SEARCH_OUTPUT_MAX_MATCHES } from 'src/engine/core-modules/tool/tools/output-navigation-tool/constants/default-search-output-max-matches.constant';
|
||||
import { SearchOutputInputZodSchema } from 'src/engine/core-modules/tool/tools/output-navigation-tool/search-output-tool.schema';
|
||||
import { searchOutput } from 'src/engine/core-modules/tool/tools/output-navigation-tool/utils/search-output.util';
|
||||
import { type ToolExecutionContext } from 'src/engine/core-modules/tool/types/tool-execution-context.type';
|
||||
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
|
||||
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
|
||||
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type SearchOutputInput = {
|
||||
fileId: string;
|
||||
pattern: string;
|
||||
maxMatches?: number;
|
||||
offset?: number;
|
||||
contextLines?: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class SearchOutputTool implements Tool {
|
||||
private readonly logger = new Logger(SearchOutputTool.name);
|
||||
|
||||
description =
|
||||
'Search (grep-like) within a large spilled tool output for a text or regex pattern, returning matching lines with surrounding context. Supports stateless pagination via offset. Use this to locate an error message or key in a file too large to inline.';
|
||||
|
||||
inputSchema = SearchOutputInputZodSchema;
|
||||
|
||||
constructor(private readonly fileService: FileService) {}
|
||||
|
||||
async execute(
|
||||
parameters: ToolInput,
|
||||
context: ToolExecutionContext,
|
||||
): Promise<ToolOutput> {
|
||||
const { workspaceId } = context;
|
||||
const { fileId, pattern, maxMatches, offset, contextLines } =
|
||||
parameters as SearchOutputInput;
|
||||
|
||||
let fileContent: { buffer: Buffer; mimeType: string } | null;
|
||||
|
||||
try {
|
||||
fileContent = await this.fileService.getFileContentById({
|
||||
fileId,
|
||||
workspaceId,
|
||||
fileFolder: FileFolder.AgentChat,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to read file ${fileId}`, error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to read output file',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
|
||||
if (!isDefined(fileContent)) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Output file not found',
|
||||
error: `File "${fileId}" not found or no longer available.`,
|
||||
};
|
||||
}
|
||||
|
||||
const rawContent = fileContent.buffer.toString('utf-8');
|
||||
|
||||
let content: string;
|
||||
|
||||
try {
|
||||
content = JSON.stringify(JSON.parse(rawContent), null, 2);
|
||||
} catch {
|
||||
content = rawContent;
|
||||
}
|
||||
|
||||
const result = searchOutput({
|
||||
content,
|
||||
pattern,
|
||||
maxMatches: maxMatches ?? DEFAULT_SEARCH_OUTPUT_MAX_MATCHES,
|
||||
offset: offset ?? 0,
|
||||
contextLines: contextLines ?? DEFAULT_SEARCH_OUTPUT_CONTEXT_LINES,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message:
|
||||
result.totalMatches === 0
|
||||
? `No matches for "${pattern}"`
|
||||
: `Found ${result.totalMatches} match(es) for "${pattern}"`,
|
||||
result,
|
||||
};
|
||||
}
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
import {
|
||||
boundValue,
|
||||
extractJsonPath,
|
||||
JsonPathError,
|
||||
parseJsonPath,
|
||||
} from 'src/engine/core-modules/tool/tools/output-navigation-tool/utils/extract-json-path.util';
|
||||
|
||||
describe('parseJsonPath', () => {
|
||||
it('returns no accessors for root', () => {
|
||||
expect(parseJsonPath('$')).toEqual([]);
|
||||
expect(parseJsonPath('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('parses dot notation', () => {
|
||||
expect(parseJsonPath('$.steps.error')).toEqual([
|
||||
{ type: 'key', key: 'steps' },
|
||||
{ type: 'key', key: 'error' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses bracket keys with dashes', () => {
|
||||
expect(parseJsonPath('$.failedStepLogs["abc-123"]')).toEqual([
|
||||
{ type: 'key', key: 'failedStepLogs' },
|
||||
{ type: 'key', key: 'abc-123' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses index and slice', () => {
|
||||
expect(parseJsonPath('$.entries[2]')).toEqual([
|
||||
{ type: 'key', key: 'entries' },
|
||||
{ type: 'index', index: 2 },
|
||||
]);
|
||||
expect(parseJsonPath('$.entries[0:5]')).toEqual([
|
||||
{ type: 'key', key: 'entries' },
|
||||
{ type: 'slice', start: 0, end: 5 },
|
||||
]);
|
||||
expect(parseJsonPath('$.entries[-5:]')).toEqual([
|
||||
{ type: 'key', key: 'entries' },
|
||||
{ type: 'slice', start: -5, end: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses wildcard', () => {
|
||||
expect(parseJsonPath('$.failedStepLogs[*].details')).toEqual([
|
||||
{ type: 'key', key: 'failedStepLogs' },
|
||||
{ type: 'wildcard' },
|
||||
{ type: 'key', key: 'details' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws when path does not start with $', () => {
|
||||
expect(() => parseJsonPath('steps.error')).toThrow(JsonPathError);
|
||||
});
|
||||
|
||||
it('throws on unexpected token', () => {
|
||||
expect(() => parseJsonPath('$.steps#error')).toThrow(JsonPathError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractJsonPath', () => {
|
||||
const data = {
|
||||
id: 'run-1',
|
||||
status: 'FAILED',
|
||||
steps: [
|
||||
{ id: 's1', status: 'SUCCESS' },
|
||||
{ id: 's2', status: 'FAILED' },
|
||||
],
|
||||
failedStepLogs: {
|
||||
s2: { entries: [1, 2, 3, 4, 5], details: { code: 'BOOM' } },
|
||||
},
|
||||
};
|
||||
|
||||
it('extracts a nested key', () => {
|
||||
expect(
|
||||
extractJsonPath({ data, path: '$.status', maxItems: 20, maxDepth: 5 }),
|
||||
).toBe('FAILED');
|
||||
});
|
||||
|
||||
it('extracts via bracket key and array index', () => {
|
||||
expect(
|
||||
extractJsonPath({
|
||||
data,
|
||||
path: '$.steps[1].id',
|
||||
maxItems: 20,
|
||||
maxDepth: 5,
|
||||
}),
|
||||
).toBe('s2');
|
||||
});
|
||||
|
||||
it('extracts an array slice', () => {
|
||||
expect(
|
||||
extractJsonPath({
|
||||
data,
|
||||
path: '$.failedStepLogs["s2"].entries[0:2]',
|
||||
maxItems: 20,
|
||||
maxDepth: 5,
|
||||
}),
|
||||
).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('extracts via wildcard over object values', () => {
|
||||
expect(
|
||||
extractJsonPath({
|
||||
data,
|
||||
path: '$.failedStepLogs[*].details',
|
||||
maxItems: 20,
|
||||
maxDepth: 5,
|
||||
}),
|
||||
).toEqual([{ code: 'BOOM' }]);
|
||||
});
|
||||
|
||||
it('throws on missing key', () => {
|
||||
expect(() =>
|
||||
extractJsonPath({ data, path: '$.nope', maxItems: 20, maxDepth: 5 }),
|
||||
).toThrow(JsonPathError);
|
||||
});
|
||||
|
||||
it('throws on out-of-range index', () => {
|
||||
expect(() =>
|
||||
extractJsonPath({ data, path: '$.steps[9]', maxItems: 20, maxDepth: 5 }),
|
||||
).toThrow(JsonPathError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('boundValue', () => {
|
||||
it('truncates arrays beyond maxItems with a marker', () => {
|
||||
const result = boundValue([1, 2, 3, 4, 5], 2, 5) as unknown[];
|
||||
|
||||
expect(result).toEqual([1, 2, '... (3 more items)']);
|
||||
});
|
||||
|
||||
it('replaces structures deeper than maxDepth with type markers', () => {
|
||||
const result = boundValue({ a: { b: { c: 1 } } }, 20, 2) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
expect(result).toEqual({ a: { b: 'object' } });
|
||||
});
|
||||
|
||||
it('marks deep arrays with their length', () => {
|
||||
const result = boundValue({ a: { b: [1, 2, 3] } }, 20, 2) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
expect(result).toEqual({ a: { b: 'array[3]' } });
|
||||
});
|
||||
|
||||
it('truncates long string leaves', () => {
|
||||
const longString = 'x'.repeat(2000);
|
||||
const result = boundValue(longString, 20, 5) as string;
|
||||
|
||||
expect(result).toContain('truncated, 2000 chars total');
|
||||
expect(result.length).toBeLessThan(longString.length);
|
||||
});
|
||||
});
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
import { searchOutput } from 'src/engine/core-modules/tool/tools/output-navigation-tool/utils/search-output.util';
|
||||
|
||||
const content = [
|
||||
'line 0 ok',
|
||||
'line 1 error: boom',
|
||||
'line 2 ok',
|
||||
'line 3 error: kaboom',
|
||||
'line 4 ok',
|
||||
'line 5 error: splat',
|
||||
].join('\n');
|
||||
|
||||
describe('searchOutput', () => {
|
||||
it('returns matches with context and line numbers', () => {
|
||||
const result = searchOutput({
|
||||
content,
|
||||
pattern: 'error',
|
||||
maxMatches: 10,
|
||||
offset: 0,
|
||||
contextLines: 1,
|
||||
});
|
||||
|
||||
expect(result.totalMatches).toBe(3);
|
||||
expect(result.hasMore).toBe(false);
|
||||
expect(result.matches[0]).toEqual({
|
||||
lineNumber: 2,
|
||||
match: 'line 1 error: boom',
|
||||
context: '1: line 0 ok\n2: line 1 error: boom\n3: line 2 ok',
|
||||
});
|
||||
});
|
||||
|
||||
it('caps results at maxMatches and reports hasMore', () => {
|
||||
const result = searchOutput({
|
||||
content,
|
||||
pattern: 'error',
|
||||
maxMatches: 2,
|
||||
offset: 0,
|
||||
contextLines: 0,
|
||||
});
|
||||
|
||||
expect(result.totalMatches).toBe(3);
|
||||
expect(result.matches).toHaveLength(2);
|
||||
expect(result.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it('paginates with offset', () => {
|
||||
const result = searchOutput({
|
||||
content,
|
||||
pattern: 'error',
|
||||
maxMatches: 2,
|
||||
offset: 2,
|
||||
contextLines: 0,
|
||||
});
|
||||
|
||||
expect(result.totalMatches).toBe(3);
|
||||
expect(result.matches).toHaveLength(1);
|
||||
expect(result.matches[0].lineNumber).toBe(6);
|
||||
expect(result.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('returns an empty result for zero matches', () => {
|
||||
const result = searchOutput({
|
||||
content,
|
||||
pattern: 'nonexistent',
|
||||
maxMatches: 10,
|
||||
offset: 0,
|
||||
contextLines: 2,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ matches: [], totalMatches: 0, hasMore: false });
|
||||
});
|
||||
|
||||
it('supports regex patterns', () => {
|
||||
const result = searchOutput({
|
||||
content,
|
||||
pattern: 'splat|boom',
|
||||
maxMatches: 10,
|
||||
offset: 0,
|
||||
contextLines: 0,
|
||||
});
|
||||
|
||||
expect(result.totalMatches).toBe(3);
|
||||
});
|
||||
|
||||
it('falls back to literal matching for invalid regex', () => {
|
||||
const result = searchOutput({
|
||||
content: 'a (b c\nd e f',
|
||||
pattern: '(b',
|
||||
maxMatches: 10,
|
||||
offset: 0,
|
||||
contextLines: 0,
|
||||
});
|
||||
|
||||
expect(result.totalMatches).toBe(1);
|
||||
expect(result.matches[0].match).toBe('a (b c');
|
||||
});
|
||||
});
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
import { isNull, isObject, isString } from '@sniptt/guards';
|
||||
import { EXTRACT_JSON_PATHS_MAX_LEAF_STRING_LENGTH } from 'src/engine/core-modules/tool/tools/output-navigation-tool/constants/extract-json-paths-max-leaf-string-length.constant';
|
||||
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
|
||||
|
||||
export class JsonPathError extends Error {}
|
||||
|
||||
type Accessor =
|
||||
| { type: 'key'; key: string }
|
||||
| { type: 'index'; index: number }
|
||||
| { type: 'slice'; start?: number; end?: number }
|
||||
| { type: 'wildcard' };
|
||||
|
||||
const KEY_DOT_REGEX = /^\.([a-zA-Z_$][a-zA-Z0-9_$]*)/;
|
||||
const KEY_DOUBLE_QUOTE_REGEX = /^\["((?:[^"\\]|\\.)*)"\]/;
|
||||
const KEY_SINGLE_QUOTE_REGEX = /^\['((?:[^'\\]|\\.)*)'\]/;
|
||||
const WILDCARD_REGEX = /^\[\*\]/;
|
||||
const SLICE_REGEX = /^\[(-?\d+)?:(-?\d+)?\]/;
|
||||
const INDEX_REGEX = /^\[(-?\d+)\]/;
|
||||
|
||||
const unescapeQuotedKey = (key: string): string => key.replace(/\\(.)/g, '$1');
|
||||
|
||||
const describeType = (value: unknown): string => {
|
||||
if (isNull(value)) {
|
||||
return 'null';
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return 'array';
|
||||
}
|
||||
|
||||
return typeof value;
|
||||
};
|
||||
|
||||
export const parseJsonPath = (path: string): Accessor[] => {
|
||||
let rest = path.trim();
|
||||
|
||||
if (rest === '' || rest === '$') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!rest.startsWith('$')) {
|
||||
throw new JsonPathError('Path must start with "$"');
|
||||
}
|
||||
|
||||
rest = rest.slice(1);
|
||||
|
||||
const accessors: Accessor[] = [];
|
||||
|
||||
while (rest.length > 0) {
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
if ((match = KEY_DOT_REGEX.exec(rest))) {
|
||||
accessors.push({ type: 'key', key: match[1] });
|
||||
} else if (
|
||||
(match = KEY_DOUBLE_QUOTE_REGEX.exec(rest)) ||
|
||||
(match = KEY_SINGLE_QUOTE_REGEX.exec(rest))
|
||||
) {
|
||||
accessors.push({ type: 'key', key: unescapeQuotedKey(match[1]) });
|
||||
} else if ((match = WILDCARD_REGEX.exec(rest))) {
|
||||
accessors.push({ type: 'wildcard' });
|
||||
} else if ((match = SLICE_REGEX.exec(rest))) {
|
||||
accessors.push({
|
||||
type: 'slice',
|
||||
start: isDefined(match[1]) ? Number(match[1]) : undefined,
|
||||
end: isDefined(match[2]) ? Number(match[2]) : undefined,
|
||||
});
|
||||
} else if ((match = INDEX_REGEX.exec(rest))) {
|
||||
accessors.push({ type: 'index', index: Number(match[1]) });
|
||||
} else {
|
||||
throw new JsonPathError(`Unexpected token in path near "${rest}"`);
|
||||
}
|
||||
|
||||
rest = rest.slice(match[0].length);
|
||||
}
|
||||
|
||||
return accessors;
|
||||
};
|
||||
|
||||
const resolveAccessors = (value: unknown, accessors: Accessor[]): unknown => {
|
||||
if (!isNonEmptyArray(accessors)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const [head, ...rest] = accessors;
|
||||
|
||||
switch (head.type) {
|
||||
case 'key': {
|
||||
if (isNull(value) || !isObject(value) || Array.isArray(value)) {
|
||||
throw new JsonPathError(
|
||||
`Cannot read key "${head.key}" on ${describeType(value)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(record, head.key)) {
|
||||
throw new JsonPathError(`Key "${head.key}" not found`);
|
||||
}
|
||||
|
||||
return resolveAccessors(record[head.key], rest);
|
||||
}
|
||||
case 'index': {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new JsonPathError(`Cannot index ${describeType(value)}`);
|
||||
}
|
||||
|
||||
const index = head.index < 0 ? value.length + head.index : head.index;
|
||||
|
||||
if (index < 0 || index >= value.length) {
|
||||
throw new JsonPathError(`Index ${head.index} is out of range`);
|
||||
}
|
||||
|
||||
return resolveAccessors(value[index], rest);
|
||||
}
|
||||
case 'slice': {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new JsonPathError(`Cannot slice ${describeType(value)}`);
|
||||
}
|
||||
|
||||
return resolveAccessors(value.slice(head.start, head.end), rest);
|
||||
}
|
||||
case 'wildcard': {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((child) => resolveAccessors(child, rest));
|
||||
}
|
||||
|
||||
if (isObject(value)) {
|
||||
return Object.values(value).map((child) =>
|
||||
resolveAccessors(child, rest),
|
||||
);
|
||||
}
|
||||
|
||||
throw new JsonPathError(
|
||||
`Cannot apply wildcard to ${describeType(value)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const boundScalar = (value: unknown): unknown => {
|
||||
if (
|
||||
isString(value) &&
|
||||
value.length > EXTRACT_JSON_PATHS_MAX_LEAF_STRING_LENGTH
|
||||
) {
|
||||
return `${value.slice(0, EXTRACT_JSON_PATHS_MAX_LEAF_STRING_LENGTH)}… (truncated, ${value.length} chars total)`;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
export const boundValue = (
|
||||
value: unknown,
|
||||
maxItems: number,
|
||||
maxDepth: number,
|
||||
depth = 0,
|
||||
): unknown => {
|
||||
if (isNull(value) || !isObject(value)) {
|
||||
return boundScalar(value);
|
||||
}
|
||||
|
||||
if (depth >= maxDepth) {
|
||||
return Array.isArray(value) ? `array[${value.length}]` : 'object';
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const limited = value
|
||||
.slice(0, maxItems)
|
||||
.map((item) => boundValue(item, maxItems, maxDepth, depth + 1));
|
||||
|
||||
if (value.length > maxItems) {
|
||||
limited.push(`... (${value.length - maxItems} more items)`);
|
||||
}
|
||||
|
||||
return limited;
|
||||
}
|
||||
|
||||
const entries = Object.entries(value);
|
||||
const output: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, child] of entries.slice(0, maxItems)) {
|
||||
output[key] = boundValue(child, maxItems, maxDepth, depth + 1);
|
||||
}
|
||||
|
||||
if (entries.length > maxItems) {
|
||||
output['...'] = `(${entries.length - maxItems} more keys)`;
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
||||
|
||||
export const extractJsonPath = ({
|
||||
data,
|
||||
path,
|
||||
maxItems,
|
||||
maxDepth,
|
||||
}: {
|
||||
data: unknown;
|
||||
path: string;
|
||||
maxItems: number;
|
||||
maxDepth: number;
|
||||
}): unknown => {
|
||||
const accessors = parseJsonPath(path);
|
||||
const resolved = resolveAccessors(data, accessors);
|
||||
|
||||
return boundValue(resolved, maxItems, maxDepth);
|
||||
};
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import { SEARCH_OUTPUT_MAX_LINE_LENGTH } from 'src/engine/core-modules/tool/tools/output-navigation-tool/constants/search-output-max-line-length.constant';
|
||||
|
||||
export type SearchMatch = {
|
||||
lineNumber: number;
|
||||
match: string;
|
||||
context: string;
|
||||
};
|
||||
|
||||
export type SearchOutputResult = {
|
||||
matches: SearchMatch[];
|
||||
totalMatches: number;
|
||||
hasMore: boolean;
|
||||
};
|
||||
|
||||
const escapeRegExp = (value: string): string =>
|
||||
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
const compilePattern = (pattern: string): RegExp => {
|
||||
try {
|
||||
return new RegExp(pattern);
|
||||
} catch {
|
||||
return new RegExp(escapeRegExp(pattern));
|
||||
}
|
||||
};
|
||||
|
||||
const truncateLine = (line: string): string =>
|
||||
line.length > SEARCH_OUTPUT_MAX_LINE_LENGTH
|
||||
? `${line.slice(0, SEARCH_OUTPUT_MAX_LINE_LENGTH)}…`
|
||||
: line;
|
||||
|
||||
export const searchOutput = ({
|
||||
content,
|
||||
pattern,
|
||||
maxMatches,
|
||||
offset,
|
||||
contextLines,
|
||||
}: {
|
||||
content: string;
|
||||
pattern: string;
|
||||
maxMatches: number;
|
||||
offset: number;
|
||||
contextLines: number;
|
||||
}): SearchOutputResult => {
|
||||
const lines = content.split('\n');
|
||||
const regex = compilePattern(pattern);
|
||||
|
||||
const matchLineIndices: number[] = [];
|
||||
|
||||
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
|
||||
if (regex.test(lines[lineIndex])) {
|
||||
matchLineIndices.push(lineIndex);
|
||||
}
|
||||
}
|
||||
|
||||
const totalMatches = matchLineIndices.length;
|
||||
const selected = matchLineIndices.slice(offset, offset + maxMatches);
|
||||
|
||||
const matches: SearchMatch[] = selected.map((lineIndex) => {
|
||||
const start = Math.max(0, lineIndex - contextLines);
|
||||
const end = Math.min(lines.length - 1, lineIndex + contextLines);
|
||||
|
||||
const contextBlock: string[] = [];
|
||||
|
||||
for (let cursor = start; cursor <= end; cursor++) {
|
||||
contextBlock.push(`${cursor + 1}: ${truncateLine(lines[cursor])}`);
|
||||
}
|
||||
|
||||
return {
|
||||
lineNumber: lineIndex + 1,
|
||||
match: truncateLine(lines[lineIndex]),
|
||||
context: contextBlock.join('\n'),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
matches,
|
||||
totalMatches,
|
||||
hasMore: offset + selected.length < totalMatches,
|
||||
};
|
||||
};
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
import { jsonPreview } from 'src/engine/core-modules/tool/utils/json-preview.util';
|
||||
|
||||
describe('jsonPreview', () => {
|
||||
it('keeps concrete scalar values', () => {
|
||||
expect(
|
||||
jsonPreview({ id: 'abc', count: 3, active: true, deleted: null }),
|
||||
).toEqual({ id: 'abc', count: 3, active: true, deleted: null });
|
||||
});
|
||||
|
||||
it('keeps every object key so the schema is visible', () => {
|
||||
const result = jsonPreview({
|
||||
a: 1,
|
||||
b: 'two',
|
||||
c: true,
|
||||
d: null,
|
||||
e: 5,
|
||||
f: 6,
|
||||
g: 7,
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(Object.keys(result)).toEqual(['a', 'b', 'c', 'd', 'e', 'f', 'g']);
|
||||
});
|
||||
|
||||
it('limits arrays to the first items and notes the remaining count', () => {
|
||||
const result = jsonPreview({
|
||||
items: Array.from({ length: 10 }, (_, index) => ({ id: index })),
|
||||
}) as { items: unknown[] };
|
||||
|
||||
expect(result.items).toEqual([
|
||||
{ id: 0 },
|
||||
{ id: 1 },
|
||||
{ id: 2 },
|
||||
'... (7 more items)',
|
||||
]);
|
||||
});
|
||||
|
||||
it('collapses dynamic-key maps whose values share a shape', () => {
|
||||
const failedStepLogs: Record<string, unknown> = {};
|
||||
|
||||
for (let index = 0; index < 8; index++) {
|
||||
failedStepLogs[`step-${index}`] = { reason: 'x' };
|
||||
}
|
||||
|
||||
const result = jsonPreview({ failedStepLogs }) as {
|
||||
failedStepLogs: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const keys = Object.keys(result.failedStepLogs);
|
||||
|
||||
expect(keys).toHaveLength(2);
|
||||
expect(keys).toContain('step-0');
|
||||
expect(keys).toContain('... (7 more keys)');
|
||||
});
|
||||
|
||||
it('does not collapse maps whose values have differing shapes', () => {
|
||||
const result = jsonPreview({
|
||||
a: { x: 1 },
|
||||
b: { y: 1 },
|
||||
c: { z: 1 },
|
||||
d: [1],
|
||||
e: 'str',
|
||||
f: 3,
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(Object.keys(result)).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('truncates long leaf strings', () => {
|
||||
const result = jsonPreview({ payload: 'a'.repeat(500) }) as {
|
||||
payload: string;
|
||||
};
|
||||
|
||||
expect(result.payload).toContain('truncated');
|
||||
expect(result.payload).toContain('total');
|
||||
});
|
||||
|
||||
it('keeps the serialized preview under the hard cap', () => {
|
||||
const deeplyNested: Record<string, unknown> = {};
|
||||
let cursor = deeplyNested;
|
||||
|
||||
for (let index = 0; index < 20; index++) {
|
||||
const child: Record<string, unknown> = {
|
||||
field0: 'value',
|
||||
field1: 'value',
|
||||
field2: 'value',
|
||||
};
|
||||
|
||||
cursor[`level-${index}`] = child;
|
||||
cursor = child;
|
||||
}
|
||||
|
||||
const result = jsonPreview(deeplyNested);
|
||||
|
||||
expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThanOrEqual(2048);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export const formatBytes = (bytes: number): string =>
|
||||
bytes >= 1024 ? `${(bytes / 1024).toFixed(1)} kB` : `${bytes} B`;
|
||||
@@ -0,0 +1,110 @@
|
||||
import { isNull, isObject, isString } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { formatBytes } from 'src/engine/core-modules/tool/utils/format-bytes.util';
|
||||
|
||||
const PREVIEW_MAX_DEPTH = 4;
|
||||
const PREVIEW_MAX_ARRAY_ITEMS = 3;
|
||||
const PREVIEW_LEAF_STRING_LENGTH = 200;
|
||||
const KEY_COLLAPSE_THRESHOLD = 5;
|
||||
const PREVIEW_HARD_CAP_BYTES = 2048;
|
||||
|
||||
const boundScalar = (value: unknown): unknown => {
|
||||
if (isString(value)) {
|
||||
const byteLength = Buffer.byteLength(value);
|
||||
|
||||
if (value.length > PREVIEW_LEAF_STRING_LENGTH) {
|
||||
return `${value.slice(0, PREVIEW_LEAF_STRING_LENGTH)}… (truncated, ${formatBytes(byteLength)} total)`;
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
const shapeSignature = (value: unknown): string => {
|
||||
if (Array.isArray(value)) {
|
||||
return 'array';
|
||||
}
|
||||
|
||||
if (isDefined(value) && isObject(value)) {
|
||||
return `object:${Object.keys(value as Record<string, unknown>)
|
||||
.sort()
|
||||
.join(',')}`;
|
||||
}
|
||||
|
||||
if (isNull(value)) {
|
||||
return 'null';
|
||||
}
|
||||
|
||||
return typeof value;
|
||||
};
|
||||
|
||||
const buildPreview = (
|
||||
value: unknown,
|
||||
maxDepth: number,
|
||||
depth: number,
|
||||
): unknown => {
|
||||
if (isNull(value) || !isObject(value)) {
|
||||
return boundScalar(value);
|
||||
}
|
||||
|
||||
if (depth >= maxDepth) {
|
||||
return Array.isArray(value) ? `array[${value.length}]` : 'object';
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const limited = value
|
||||
.slice(0, PREVIEW_MAX_ARRAY_ITEMS)
|
||||
.map((item) => buildPreview(item, maxDepth, depth + 1));
|
||||
|
||||
if (value.length > PREVIEW_MAX_ARRAY_ITEMS) {
|
||||
limited.push(
|
||||
`... (${value.length - PREVIEW_MAX_ARRAY_ITEMS} more items)`,
|
||||
);
|
||||
}
|
||||
|
||||
return limited;
|
||||
}
|
||||
|
||||
const entries = Object.entries(value as Record<string, unknown>);
|
||||
|
||||
if (entries.length > KEY_COLLAPSE_THRESHOLD) {
|
||||
const signatures = new Set(entries.map(([, val]) => shapeSignature(val)));
|
||||
|
||||
if (signatures.size === 1) {
|
||||
const [representativeKey, representativeValue] = entries[0];
|
||||
|
||||
return {
|
||||
[representativeKey]: buildPreview(
|
||||
representativeValue,
|
||||
maxDepth,
|
||||
depth + 1,
|
||||
),
|
||||
[`... (${entries.length - 1} more keys)`]: '...',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const output: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, child] of entries) {
|
||||
output[key] = buildPreview(child, maxDepth, depth + 1);
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
||||
|
||||
export const jsonPreview = (value: unknown): unknown => {
|
||||
let maxDepth = PREVIEW_MAX_DEPTH;
|
||||
let preview = buildPreview(value, maxDepth, 0);
|
||||
|
||||
while (
|
||||
maxDepth > 1 &&
|
||||
Buffer.byteLength(JSON.stringify(preview) ?? '') > PREVIEW_HARD_CAP_BYTES
|
||||
) {
|
||||
maxDepth -= 1;
|
||||
preview = buildPreview(value, maxDepth, 0);
|
||||
}
|
||||
|
||||
return preview;
|
||||
};
|
||||
+2
@@ -25,6 +25,7 @@ import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/servi
|
||||
import { estimateToolOutputTokens } from 'src/engine/core-modules/tool-provider/utils/estimate-tool-output-tokens.util';
|
||||
import { getToolMetricName } from 'src/engine/core-modules/tool-provider/utils/get-tool-metric-name.util';
|
||||
import { isToolOutputSuccessful } from 'src/engine/core-modules/tool-provider/utils/is-tool-output-successful.util';
|
||||
import { OUTPUT_NAVIGATION_TOOL_NAMES } from 'src/engine/core-modules/tool/tools/output-navigation-tool/constants/output-navigation-tool-names.constant';
|
||||
import { UsageOperationType } from 'src/engine/core-modules/usage/enums/usage-operation-type.enum';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { WORKFLOW_AGENT_REGISTRY_TOOL_CATEGORIES } from 'src/engine/metadata-modules/ai/ai-agent-execution/constants/workflow-agent-registry-tool-categories.const';
|
||||
@@ -196,6 +197,7 @@ export class AgentAsyncExecutorService {
|
||||
toolProviderContext,
|
||||
{
|
||||
categories: WORKFLOW_AGENT_REGISTRY_TOOL_CATEGORIES,
|
||||
excludeTools: [...OUTPUT_NAVIGATION_TOOL_NAMES],
|
||||
wrapWithErrorContext: false,
|
||||
},
|
||||
);
|
||||
|
||||
+2
-2
@@ -152,7 +152,7 @@ export class ChatExecutionService {
|
||||
const preloadedTools = await this.toolRegistry.getToolsByName(
|
||||
AI_CHAT_TOOL_NAMES_TO_PRELOAD,
|
||||
toolContext,
|
||||
{ compactOutput: true },
|
||||
{ compactOutput: true, spillLargeOutput: true },
|
||||
);
|
||||
|
||||
const resolvedModelId = modelId ?? workspace.smartModel;
|
||||
@@ -204,7 +204,7 @@ export class ChatExecutionService {
|
||||
[EXECUTE_TOOL_TOOL_NAME]: createExecuteToolTool(
|
||||
this.toolRegistry,
|
||||
toolContext,
|
||||
{ compactOutput: true },
|
||||
{ compactOutput: true, spillLargeOutput: true },
|
||||
),
|
||||
[LOAD_SKILL_TOOL_NAME]: createLoadSkillTool(
|
||||
(skillNames) =>
|
||||
|
||||
@@ -37389,6 +37389,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"install-artifact-from-github@npm:^1.6.0":
|
||||
version: 1.6.0
|
||||
resolution: "install-artifact-from-github@npm:1.6.0"
|
||||
bin:
|
||||
install-from-cache: bin/install-from-cache.js
|
||||
save-to-github-cache: bin/save-to-github-cache.js
|
||||
checksum: 10c0/e480c994a6230ba9c24b76e29908dcfb29977d3ee5b4a193b5c793139065aa0deb9bc5f475d11fd347c516b88ddb1a0f02935aa11fd1d3585761a4a4ccc1885c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"internal-slot@npm:^1.1.0":
|
||||
version: 1.1.0
|
||||
resolution: "internal-slot@npm:1.1.0"
|
||||
@@ -43238,6 +43248,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"nan@npm:^2.27.0":
|
||||
version: 2.27.0
|
||||
resolution: "nan@npm:2.27.0"
|
||||
dependencies:
|
||||
node-gyp: "npm:latest"
|
||||
checksum: 10c0/38eb00b06e40f0c65b6a98d75795f17d651a8b7b52f03873dff6902d0053f12e7638d7f64fc52bda6c8f8ec454d69636e3988c8a9eb2bc749c2d5c255ba55f4c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"nan@npm:^2.4.0":
|
||||
version: 2.26.2
|
||||
resolution: "nan@npm:2.26.2"
|
||||
@@ -43768,7 +43787,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"node-gyp@npm:latest":
|
||||
"node-gyp@npm:^13.0.0, node-gyp@npm:latest":
|
||||
version: 13.0.0
|
||||
resolution: "node-gyp@npm:13.0.0"
|
||||
dependencies:
|
||||
@@ -47520,6 +47539,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"re2@npm:^1.25.0":
|
||||
version: 1.25.0
|
||||
resolution: "re2@npm:1.25.0"
|
||||
dependencies:
|
||||
install-artifact-from-github: "npm:^1.6.0"
|
||||
nan: "npm:^2.27.0"
|
||||
node-gyp: "npm:^13.0.0"
|
||||
checksum: 10c0/a1b89e4d6c594d3fe675fc18f5ee3ce06ea83d66ebc5b3b2d66abf8c93c57757478969d1c934db4379ffba0575154c149d4168a567d192a936fced369d7c3ecf
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-aria@npm:^3.48.0":
|
||||
version: 3.49.0
|
||||
resolution: "react-aria@npm:3.49.0"
|
||||
@@ -54024,6 +54054,7 @@ __metadata:
|
||||
postal-mime: "npm:^2.6.1"
|
||||
prettier: "npm:^3.1.1"
|
||||
psl: "npm:^1.9.0"
|
||||
re2: "npm:^1.25.0"
|
||||
react: "npm:^19.2.0"
|
||||
react-dom: "npm:^19.2.0"
|
||||
redis: "npm:^4.7.0"
|
||||
|
||||
Reference in New Issue
Block a user