Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/tools/dev/report_problem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { ToolEntry, ToolInputSchema } from '../../types.js';
import { TOOL_TYPE } from '../../types.js';
import { compileSchema } from '../../utils/ajv.js';
import { respondOk } from '../../utils/mcp.js';
import { reportProblemToolOutputSchema } from '../structured_output_schemas.js';

const REPORT_PROBLEM_DESCRIPTION = `Report a problem with Apify's MCP tools or Actors to the Apify team.

Expand Down Expand Up @@ -127,8 +128,7 @@ export const reportProblem: ToolEntry = Object.freeze({
title: 'Report a problem',
description: REPORT_PROBLEM_DESCRIPTION,
inputSchema: reportProblemInputSchema,
// TODO(#1159): no `outputSchema` — the tool returns a fixed acknowledgement, so nothing here is
// affected by the `tools/call` result projection against an advertised schema.
outputSchema: reportProblemToolOutputSchema,
ajvValidate: compileSchema(reportProblemInputSchema),
paymentRequired: false,
annotations: {
Expand All @@ -139,6 +139,6 @@ export const reportProblem: ToolEntry = Object.freeze({
openWorldHint: false,
},
call: async () => {
return respondOk(ACKNOWLEDGEMENT);
return respondOk(ACKNOWLEDGEMENT, { structuredContent: { reported: true } });
},
} as const);
20 changes: 12 additions & 8 deletions src/tools/runs/get_actor_run_log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,16 @@ import { HELPER_TOOLS } from '../../const.js';
import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../../types.js';
import { TOOL_TYPE } from '../../types.js';
import { compileSchema } from '../../utils/ajv.js';
import { respondRaw } from '../../utils/mcp.js';
import { respondOk } from '../../utils/mcp.js';
import { getActorRunLogToolOutputSchema } from '../structured_output_schemas.js';

const GetRunLogArgs = z.object({
runId: z.string().describe('The ID of the Actor run.'),
lines: z.number().max(50).describe('Output the last NUM lines, instead of the last 10').default(10),
lines: z
.number()
.max(50)
.describe('Output the last NUM lines, instead of the last 10. Pass 0 to return the entire log.')
.default(10),
});

/**
Expand All @@ -29,9 +34,7 @@ USAGE EXAMPLES:
- user_input: Show last 20 lines of logs for run y2h7sK3Wc
- user_input: Get logs for run y2h7sK3Wc`,
inputSchema: z.toJSONSchema(GetRunLogArgs) as ToolInputSchema,
// It does not make sense to add structured output here since the log API just returns plain text
// TODO(#1160): no `outputSchema`, so the `tools/call` result projection against an advertised
// schema does not apply to this tool either way.
outputSchema: getActorRunLogToolOutputSchema,
ajvValidate: compileSchema(z.toJSONSchema(GetRunLogArgs)),
paymentRequired: true,
annotations: {
Expand All @@ -45,8 +48,9 @@ USAGE EXAMPLES:
const { args, apifyClient: client } = toolArgs;
const parsed = GetRunLogArgs.parse(args);
const v = (await client.run(parsed.runId).log().get()) ?? '';
const lines = v.split('\n');
const text = lines.slice(lines.length - parsed.lines - 1, lines.length).join('\n');
return respondRaw({ content: [{ type: 'text', text }] });
// Logs from the API end with a newline; drop it so the tail slice counts only content lines.
const lines = v.replace(/\n$/, '').split('\n');
const text = lines.slice(-parsed.lines).join('\n');
return respondOk(text, { structuredContent: { log: text } });
},
} as const);
22 changes: 22 additions & 0 deletions src/tools/structured_output_schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,28 @@ export const fetchApifyDocsToolOutputSchema = {
required: ['url', 'content'],
};

/**
* Schema for the fixed acknowledgement returned by report-problem.
*/
export const reportProblemToolOutputSchema = {
type: 'object' as const,
properties: {
reported: { type: 'boolean', description: 'Always true; the problem report was submitted' },
},
required: ['reported'],
};

/**
* Schema for get-actor-log. The log API returns plain text, so the schema wraps it in a single field.
*/
export const getActorRunLogToolOutputSchema = {
type: 'object' as const,
properties: {
log: { type: 'string', description: 'The last N lines of the run log, as plain text' },
},
required: ['log'],
};

// Per-storage entry shapes. Factories (not shared constants) because `structuredClone` preserves
// object identity: if `default` and `additionalProperties` referenced the same object, cloning
// `actorRunOutputSchema` would keep them as the same object, and injecting `itemsSchema` into
Expand Down
116 changes: 116 additions & 0 deletions tests/unit/tools.get_actor_run_log.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { describe, expect, it, vi } from 'vitest';

import { HELPER_TOOLS } from '../../src/const.js';
import { getActorRunLog } from '../../src/tools/runs/get_actor_run_log.js';
import { getActorRunLogToolOutputSchema } from '../../src/tools/structured_output_schemas.js';
import type { HelperTool, InternalToolArgs } from '../../src/types.js';
import {
expectSchemaConformingStructuredContent,
stubToolCallContext,
type TextToolResult,
} from './helpers/tool_context.js';

const getMock = vi.fn();

const stubClient = { run: () => ({ log: () => ({ get: getMock }) }) } as unknown as InternalToolArgs['apifyClient'];

const numberedLog = (count: number) => Array.from({ length: count }, (_, i) => `line ${i + 1}`).join('\n');

const callTool = async (args: Record<string, unknown>) =>
(await (getActorRunLog as HelperTool).call(stubToolCallContext(args, stubClient))) as TextToolResult;

describe('get-actor-log', () => {
it('has the expected tool name', () => {
expect(getActorRunLog.name).toBe(HELPER_TOOLS.ACTOR_RUNS_LOG);
});

it('returns exactly the requested number of trailing lines', async () => {
getMock.mockResolvedValue(numberedLog(20));

const { content } = await callTool({ runId: 'run-1', lines: 10 });
const returned = content[0].text.split('\n');

expect(returned).toHaveLength(10);
expect(returned).toEqual([
'line 11',
'line 12',
'line 13',
'line 14',
'line 15',
'line 16',
'line 17',
'line 18',
'line 19',
'line 20',
]);
});

it('returns a single line when one line is requested', async () => {
getMock.mockResolvedValue(numberedLog(20));

const { content } = await callTool({ runId: 'run-1', lines: 1 });

expect(content[0].text).toBe('line 20');
});

it('returns exactly the default 10 lines when lines is omitted', async () => {
getMock.mockResolvedValue(numberedLog(50));

const { content } = await callTool({ runId: 'run-1' });

expect(content[0].text.split('\n')).toHaveLength(10);
});

it('returns only content lines when the log ends with a newline', async () => {
getMock.mockResolvedValue(`${numberedLog(20)}\n`);

const { content } = await callTool({ runId: 'run-1', lines: 3 });
const returned = content[0].text.split('\n');

expect(returned).toEqual(['line 18', 'line 19', 'line 20']);
});

it('returns the last content line when one line is requested and the log ends with a newline', async () => {
getMock.mockResolvedValue(`${numberedLog(20)}\n`);

const { content } = await callTool({ runId: 'run-1', lines: 1 });

expect(content[0].text).toBe('line 20');
});

it('keeps a trailing blank line that is followed by nothing but one newline', async () => {
getMock.mockResolvedValue(`${numberedLog(3)}\n\n`);

const { content } = await callTool({ runId: 'run-1', lines: 2 });

expect(content[0].text.split('\n')).toEqual(['line 3', '']);
});

it('returns the whole log when it is shorter than the requested number of lines', async () => {
getMock.mockResolvedValue(numberedLog(3));

const { content } = await callTool({ runId: 'run-1', lines: 10 });

expect(content[0].text).toBe('line 1\nline 2\nline 3');
});

it('mirrors the log text in structuredContent and declares an outputSchema', async () => {
getMock.mockResolvedValue(numberedLog(20));

const result = await callTool({ runId: 'run-1', lines: 10 });

expect(result.structuredContent).toEqual({ log: result.content[0].text });
expect((getActorRunLog as HelperTool).outputSchema).toBe(getActorRunLogToolOutputSchema);
expectSchemaConformingStructuredContent(result, getActorRunLogToolOutputSchema);
});

it('returns conforming structuredContent for an empty log', async () => {
getMock.mockResolvedValue(undefined);

const result = await callTool({ runId: 'run-1', lines: 10 });

expect(result.content[0].text).toBe('');
expect(result.structuredContent).toEqual({ log: '' });
expectSchemaConformingStructuredContent(result, getActorRunLogToolOutputSchema);
});
});
17 changes: 16 additions & 1 deletion tests/unit/tools.report_problem.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,13 @@ import {
appendReportProblemNudge,
reportProblem,
} from '../../src/tools/dev/report_problem.js';
import { reportProblemToolOutputSchema } from '../../src/tools/structured_output_schemas.js';
import type { HelperTool } from '../../src/types.js';
import { type TextToolResult, stubToolCallContext } from './helpers/tool_context.js';
import {
expectSchemaConformingStructuredContent,
type TextToolResult,
stubToolCallContext,
} from './helpers/tool_context.js';

const errorResult = () => ({ content: [{ type: 'text', text: 'Actor not found.' }], isError: true });
const nudgeCount = (r: { content: { text: string }[] }) =>
Expand All @@ -25,6 +30,16 @@ describe('reportProblem', () => {
expect(isError).toBe(false);
expect(content[0].text).toContain('Problem reported');
});

it('returns structuredContent conforming to the declared outputSchema', async () => {
const result = await (reportProblem as HelperTool).call(
stubToolCallContext({ message: 'The search-actors results were unclear.' }, {} as never),
);

expect((result as TextToolResult).structuredContent).toEqual({ reported: true });
expect((reportProblem as HelperTool).outputSchema).toBe(reportProblemToolOutputSchema);
expectSchemaConformingStructuredContent(result, reportProblemToolOutputSchema);
});
});

describe('annotations', () => {
Expand Down
Loading