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
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@
</form>
</mat-card>

<mat-card *ngIf="agentDetails()?.state === 'hil'" class="p-3 mb-4">
<mat-card *ngIf="agentDetails()?.state === 'hitl_threshold'" class="p-3 mb-4">
<form [formGroup]="hilForm" (ngSubmit)="onResumeHil()">
<mat-card-title class="font-bold pl-5 text-lg">Human In Loop check</mat-card-title>
<mat-card-content>
Expand Down
1 change: 0 additions & 1 deletion src/agent/autonomous/codegen/agentImageUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ export interface ImageSource {
* @returns An array of ImagePartExt objects ready to be included in the LLM prompt.
*/
export async function checkForImageSources(result: Record<string, any>, fileStore?: FileStore): Promise<ImagePartExt[]> {
logger.info('checkForImageSources');
const imageParts: ImagePartExt[] = [];

if (!result || typeof result !== 'object') return imageParts;
Expand Down
2 changes: 1 addition & 1 deletion src/agent/autonomous/codegen/codeGenAgentCodeReview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Your task is to review the code provided to ensure it follows the following inst
${agentPlanResponse}
</current-plan>

First think through your review of the code in the <python-code> tags against all the review instructions.
First output through your review of the code in the <python-code> tags against each of the review instructions.
Then output the updated code to go in main() method wrapped in <result></result> tags without any extra indentation.
If there are no changes to make then output the existing code as is in the result tags.
`;
Expand Down
53 changes: 35 additions & 18 deletions src/agent/autonomous/codegen/codegenAutonomousAgent.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { type Span, SpanStatusCode } from '@opentelemetry/api';
import { type PyodideInterface, loadPyodide } from 'pyodide';
import type { AgentExecution } from '#agent/agentExecutions';
Expand Down Expand Up @@ -226,7 +227,6 @@ async function runAgentExecution(agent: AgentContext, span: Span): Promise<strin
stopSequences,
temperature: AGENT_TEMPERATURE,
thinking: 'high',
maxOutputTokens: 32000,
});
agentPlanResponse = messageText(agentPlanResponseMessage);
pythonMainFnCode = extractPythonCode(agentPlanResponse);
Expand All @@ -237,7 +237,6 @@ async function runAgentExecution(agent: AgentContext, span: Span): Promise<strin
stopSequences,
temperature: AGENT_TEMPERATURE,
thinking: 'high',
maxOutputTokens: 32000,
});
agentPlanResponse = messageText(agentPlanResponseMessage);
pythonMainFnCode = extractPythonCode(agentPlanResponse);
Expand Down Expand Up @@ -333,7 +332,7 @@ async function runAgentExecution(agent: AgentContext, span: Span): Promise<strin
}

const lastFunctionCall = agent.functionCallHistory.length ? agent.functionCallHistory[agent.functionCallHistory.length - 1] : null;
logger.info(`Last function call was ${lastFunctionCall?.function_name}`);
logger.debug(`Last function call was ${lastFunctionCall?.function_name}`);
// Check for agent completion or feedback request
if (lastFunctionCall?.function_name === AGENT_COMPLETED_NAME) {
logger.info(`Task completed: ${lastFunctionCall.parameters[AGENT_COMPLETED_PARAM_NAME]}`);
Expand Down Expand Up @@ -571,36 +570,54 @@ function setupPyodideFunctionProxies(

/**
* Generates Python code with a helper function and minimal wrappers
* to automatically perform a shallow conversion (.to_py(depth=1)) on JsProxy results.
* to automatically perform deep conversion to native Python types on JsProxy results.
*/
export function generatePythonWrapper(schemas: FunctionSchema[], generatedPythonCode: string): string {
let helperAndWrapperCode = `
import sys
import traceback # Keep traceback for JS call errors
import asyncio

try:
from pyodide.ffi import JsProxy
except ImportError:
print("Warning: pyodide.ffi.JsProxy not found.", file=sys.stderr)
class JsProxy: pass # Dummy class

def _try_shallow_convert_proxy(result, func_name_for_log: str):
def _convert_result(result, func_name_for_log: str):
"""
Internal helper: Attempts shallow conversion (.to_py(depth=1)) if result is JsProxy.
Returns converted value or original result.
Internal helper: Deeply convert JsProxy results (lists/dicts/nested) to Python types.
"""
if isinstance(result, JsProxy):
try:
# Attempt shallow conversion (converts top-level obj/arr)
return result.to_py(depth=1)
except Exception as e_conv:
# If conversion fails, log warning and return original proxy
print(f"Warning: Failed to shallow convert result of {func_name_for_log}: {e_conv}", file=sys.stderr)
return result # Fallback to the proxy
else:
# If not a proxy (e.g., primitive), return directly
try:
if isinstance(result, JsProxy):
# Full conversion of JsProxy to native Python types
return result.to_py()
if isinstance(result, (list, tuple)):
return [ _convert_result(x, func_name_for_log) for x in result ]
if isinstance(result, dict):
return { k: _convert_result(v, func_name_for_log) for k, v in result.items() }
return result
except Exception as e_conv:
print(f"Warning: Failed to convert result of {func_name_for_log}: {e_conv}", file=sys.stderr)
return result

# --- Concurrency helpers exposed to generated code (no explicit imports needed) ---
async def gather(*aws, return_exceptions: bool = False):
"""
Run multiple awaited operations in parallel and return their results.
Usage:
c1 = FuncA(...)
c2 = FuncB(...)
r1, r2 = await gather(c1, c2)
"""
return await asyncio.gather(*aws, return_exceptions=return_exceptions)

def create_task(coro):
"""
Schedule a coroutine to run concurrently. Returns an asyncio.Task.
"""
return asyncio.create_task(coro)

`;

// --- Generate the minimal wrappers ---
Expand All @@ -613,7 +630,7 @@ def _try_shallow_convert_proxy(result, func_name_for_log: str):
async def ${originalName}(*args, **kwargs):
try:
raw_result = await ${internalName}(*args, **kwargs)
return _try_shallow_convert_proxy(raw_result, '${originalName}')
return _convert_result(raw_result, '${originalName}')
except Exception as e_call:
print(f"Error during call to underlying JS function '${internalName}': {e_call}", file=sys.stderr)
# Optionally print traceback for detailed debugging
Expand Down
119 changes: 119 additions & 0 deletions src/agent/autonomous/codegen/pyodideDeepConversion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { expect } from 'chai';
import { type PyodideInterface, loadPyodide } from 'pyodide';
import type { FunctionSchema } from '#functionSchema/functions';
import { generatePythonWrapper } from './codegenAutonomousAgent';
import { mainFnCodeToFullScript } from './pythonCodeGenUtils';

describe('Pyodide deep conversion in wrappers', () => {
let py: PyodideInterface;

before(async function () {
this.timeout(60000);
py = await loadPyodide();
});

it('allows subscripting and membership on returned arrays/dicts', async function () {
this.timeout(30000);

const schemas: FunctionSchema[] = [
{ class: 'GitLab', name: 'GitLab_getProjects', description: 'list projects', returns: '', parameters: [], returnType: 'Array<Record<string, any>>' },
];
// Ensure wrapper is generated for this function by including the name in "generatedPythonCode"
const wrapper = generatePythonWrapper(schemas, 'await GitLab_getProjects()');

const main = `
projects = await GitLab_getProjects()
first = projects[0]
owner_name = first['owner']['name']
ok = 'group/subgroup' in first['fullPath']

# Count matches across the list
count = 0
for p in projects:
if 'group/subgroup' in p['fullPath']:
count += 1

# Mutate nested field to ensure dict/list semantics work
first['processed'] = True

return {
'ok': ok,
'count': count,
'ownerName': owner_name,
'firstName': first['name'],
'processed': first['processed'],
'length': len(projects),
}
`.trim();

const script = wrapper + mainFnCodeToFullScript(main);

const projects = [
{ name: 'project-a', fullPath: 'group/subgroup/project-a', owner: { name: 'team-a' } },
{ name: 'project-b', fullPath: 'group/subgroup/project-b', owner: { name: 'team-b' } },
];

const globals = py.toPy({
_GitLab_getProjects: async () => projects,
});

const pyResult: any = await py.runPythonAsync(script, { globals });
const result = pyResult?.toJs ? pyResult.toJs({ dict_converter: Object.fromEntries }) : pyResult;
if (pyResult?.destroy) pyResult.destroy?.();

expect(result.ok).to.equal(true);
expect(result.count).to.equal(2);
expect(result.ownerName).to.equal('team-a');
expect(result.firstName).to.equal('project-a');
expect(result.processed).to.equal(true);
expect(result.length).to.equal(2);
});

it('supports deep list/dict manipulation of returned structures', async function () {
this.timeout(30000);

const schemas: FunctionSchema[] = [
{ class: 'TestApi', name: 'Api_getData', description: 'get data', returns: '', parameters: [], returnType: 'Record<string, any>' },
];
const wrapper = generatePythonWrapper(schemas, 'await Api_getData()');

const main = `
data = await Api_getData()

# Mutate nested list and compute aggregate from nested dict
data['items'][0]['tags'].append('new')
total = sum([v for v in data['metrics'].values()])

return {
'firstTags': data['items'][0]['tags'],
'total': total,
'hasNew': 'new' in data['items'][0]['tags'],
'hasNewInOriginal': 'new' in data['items'][0]['tags'], # Check if original object is modified
}
`.trim();

const script = wrapper + mainFnCodeToFullScript(main);

const jsData = {
items: [
{ id: 1, tags: ['a', 'b'] },
{ id: 2, tags: [] },
],
metrics: { a: 1, b: 2, c: 3 },
};

const globals = py.toPy({
_Api_getData: async () => jsData,
});

const pyResult: any = await py.runPythonAsync(script, { globals });
const result = pyResult?.toJs ? pyResult.toJs({ dict_converter: Object.fromEntries }) : pyResult;
if (pyResult?.destroy) pyResult.destroy?.();

expect(result.firstTags).to.deep.equal(['a', 'b', 'new']);
expect(result.total).to.equal(6);
expect(result.hasNew).to.equal(true);
// Ensure the original JS object is NOT modified by Python operations
expect(jsData.items[0].tags).to.deep.equal(['a', 'b']);
});
});
36 changes: 33 additions & 3 deletions src/modules/slack/slackApi.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { ConversationsHistoryResponse, ConversationsListResponse, ConversationsRepliesResponse, WebClient } from '@slack/web-api';
import { MessageElement } from '@slack/web-api/dist/types/response/ConversationsHistoryResponse';
import { llms } from '#agent/agentContextLocalStorage';
import { logger } from '#o11y/logger';
import { formatAsSlackBlocks } from './slackBlockFormatter';
import { SlackConfig, slackConfig } from './slackConfig';
import { textToBlocks } from './slackMessageFormatter';

/**
* A class to interact with the Slack API, specifically for fetching conversations and messages.
Expand All @@ -10,13 +13,40 @@ export class SlackAPI {
private client: WebClient;
private config: SlackConfig = slackConfig();

/**
* Constructs a new SlackAPI instance.
*/
constructor() {
this.client = new WebClient(this.config.botToken);
}

async postMessage(channelId: string, threadTs: string, message: string, reply_ts?: string) {
const params: any = {
channel: channelId,
thread_ts: threadTs,
blocks: await formatAsSlackBlocks(message, llms().easy),
text: message,
};

try {
let result = await this.client.chat.postMessage(params);

if (!result.ok && result.error === 'invalid_blocks_format') {
logger.info({ blocks: params.blocks }, 'Slack invalid_blocks_format. Retrying with medium LLM');
params.blocks = await formatAsSlackBlocks(message, llms().medium);
result = await this.client.chat.postMessage(params);
}

if (!result.ok && result.error === 'invalid_blocks_format') {
logger.info({ blocks: params.blocks }, 'Slack invalid_blocks_format. Retrying with textToBlocks');
params.blocks = textToBlocks(message);
result = await this.client.chat.postMessage(params);
}

if (!result.ok) throw new Error(`Failed to send message to Slack: ${result.error}`);
} catch (error) {
logger.error(error, 'Error sending message to Slack');
throw error;
}
}

async getConversationReplies(channelId: string, threadTs: string, limit = 100): Promise<MessageElement[]> {
let cursor: string | undefined;
const allMessages: MessageElement[] = [];
Expand Down
17 changes: 9 additions & 8 deletions src/modules/slack/slackBlockFormatter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { llms } from '#agent/agentContextLocalStorage';
import { LLM } from '#shared/llm/llm.model';
import { convertMarkdownToMrkdwn } from './slackMessageFormatter';

/*
Expand Down Expand Up @@ -77,9 +78,7 @@ const SLACK_BLOCKS_SCHEMA = {
required: ['blocks'],
};

interface SlackBlocks {
blocks: Array<MarkdownBlock | DividerBlock | TableBlock>;
}
type SlackBlocks = Array<MarkdownBlock | DividerBlock | TableBlock>;

const SLACK_MARKDOWN_FORMATTING_RULES = [
'## Markdown Formatting Rules Overview',
Expand Down Expand Up @@ -167,7 +166,7 @@ const SLACK_MARKDOWN_FORMATTING_RULES = [
* Formats markdown to Slack blocks, using markdown blocks, table blocks and divider blocks, as the Slack markdown doesn't support code block with syntax highlighting, horizontal lines, tables, and task list.
* @param message
*/
export async function formatAsSlackBlocks(markdown: string): Promise<SlackBlocks> {
export async function formatAsSlackBlocks(markdown: string, llm: LLM): Promise<SlackBlocks> {
const prompt = `<message>${markdown}</message>\n\nYou are a Slack block formatter. Convert the message text/markdown to Slack blocks.

<formatting-rules>
Expand Down Expand Up @@ -201,12 +200,14 @@ interface TableBlock {
column_settings?: Array<{ align?: string, is_wrapped?: boolean }>
}

Return only a JSON object matching the type
{
interface SlackBlocks {
blocks: Array<MarkdownBlock | DividerBlock | TableBlock>
}

Return only a JSON object matching the type SlackBlocks

</response-format>`;
const blocks: SlackBlocks = await llms().easy.generateJson(prompt, { jsonSchema: SLACK_BLOCKS_SCHEMA, id: ' Markdown block formatter', temperature: 0 });
const blocks: { blocks: SlackBlocks } = await llm.generateJson(prompt, { jsonSchema: SLACK_BLOCKS_SCHEMA, id: ' Markdown block formatter', temperature: 0 });
for (const block of blocks.blocks) if (block.type === 'markdown') block.text = convertMarkdownToMrkdwn(block.text);
return blocks;
return blocks.blocks;
}
Loading
Loading