mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-12 01:41:26 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9d7f6fd76 | ||
|
|
cc5360e60e | ||
|
|
863dd9bc34 | ||
|
|
8c39769971 | ||
|
|
93904f8530 | ||
|
|
ffc3775446 | ||
|
|
86e76b7a3e | ||
|
|
b18d6007c2 | ||
|
|
c656af3166 | ||
|
|
3c2fff05dd | ||
|
|
092097faa6 | ||
|
|
068ddecb91 | ||
|
|
444d14cb54 | ||
|
|
090316dce3 | ||
|
|
a40cddd402 | ||
|
|
62472a2342 | ||
|
|
35186a0e66 | ||
|
|
d08d98ca12 | ||
|
|
2c9e8b46cb | ||
|
|
ce3b25c085 | ||
|
|
a928467188 | ||
|
|
8285afe789 | ||
|
|
b529110246 | ||
|
|
2a004bc382 | ||
|
|
b3fecacb2b | ||
|
|
6924077e36 | ||
|
|
63101d7323 | ||
|
|
e9540b2a92 | ||
|
|
75c1c62cec | ||
|
|
de4f251a02 | ||
|
|
d4858b4d5c | ||
|
|
03ca92a7d5 | ||
|
|
cca65dcdab | ||
|
|
93844d9509 | ||
|
|
0849db227d | ||
|
|
914d34e57b | ||
|
|
be66a5e4e1 | ||
|
|
20a19f7958 | ||
|
|
4128a67e10 | ||
|
|
9d29c796c7 | ||
|
|
f058ce018f | ||
|
|
09671d4bd3 |
No files matched your search
@@ -12,7 +12,7 @@ from praisonaiagents import Agent, Agents
|
||||
|
||||
Methods:
|
||||
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">achat</a>(prompt: str, temperature = 1.0, tools = None, output_json = None, output_pydantic = None, reasoning_steps = False, task_name = None, task_description = None, task_id = None)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">achat</a>(prompt: str, temperature = 1.0, tools = None, output_json = None, output_pydantic = None, reasoning_steps = False, task_name = None, task_description = None, task_id = None, attachments = None)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">aexecute</a>(task, context = None)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">agent_id</a>()</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">analyze_prompt</a>(prompt: str) -> set</code>
|
||||
@@ -22,7 +22,7 @@ Methods:
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">auto_memory</a>(value)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">background</a>()</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">background</a>(value)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">chat</a>(prompt, temperature = 1.0, tools = None, output_json = None, output_pydantic = None, reasoning_steps = False, stream = None, task_name = None, task_description = None, task_id = None, config = None, force_retrieval = False, skip_retrieval = False)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">chat</a>(prompt, temperature = 1.0, tools = None, output_json = None, output_pydantic = None, reasoning_steps = False, stream = None, task_name = None, task_description = None, task_id = None, config = None, force_retrieval = False, skip_retrieval = False, attachments = None)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">chat_with_context</a>(message: str, context: 'ContextPack', **kwargs) -> str</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">checkpoints</a>()</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">checkpoints</a>(value)</code>
|
||||
@@ -31,13 +31,18 @@ Methods:
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">console</a>()</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">context_manager</a>()</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">context_manager</a>(value)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">delete_history</a>(index: int) -> bool</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">delete_history_matching</a>(pattern: str) -> int</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">display_generating</a>(content: str, start_time: float)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">display_name</a>() -> str</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">ephemeral</a>()</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">execute</a>(task, context = None)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">execute_tool</a>(function_name, arguments)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">execute_tool_async</a>(function_name: str, arguments: Dict[str, Any]) -> Any</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">from_template</a>(uri: str, config: Optional[Dict[str, Any]] = None, offline: bool = False, **kwargs) -> 'Agent'</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">generate_task</a>() -> 'Task'</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">get_available_tools</a>() -> List[Any]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">get_history_size</a>() -> int</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">get_memory_context</a>(query: Optional[str] = None) -> str</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">get_recommended_stage</a>(prompt: str) -> str</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">get_rules_context</a>(file_path: Optional[str] = None, include_manual: Optional[List[str]] = None) -> str</code>
|
||||
@@ -51,6 +56,7 @@ Methods:
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">output_style</a>(value)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">policy</a>()</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">policy</a>(value)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">prune_history</a>(keep_last: int = 5) -> int</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">query</a>(question: str, **kwargs) -> 'RAGResult'</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">rag</a>()</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">rag_query</a>(question: str, **kwargs) -> 'RAGResult'</code>
|
||||
@@ -121,7 +127,7 @@ Methods:
|
||||
|
||||
Types:
|
||||
```python
|
||||
from praisonaiagents import Task
|
||||
from praisonaiagents import EmbeddingResult, Task, aembedding, aembeddings, embedding, embeddings, get_dimensions
|
||||
```
|
||||
|
||||
Methods:
|
||||
@@ -130,6 +136,9 @@ Methods:
|
||||
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">execute_callback_sync</a>(task_output: TaskOutput) -> None</code>
|
||||
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">initialize_memory</a>()</code>
|
||||
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">store_in_memory</a>(content: str, agent_name: str = None, task_id: str = None)</code>
|
||||
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/embedding/embed.py">aembedding</a>(input: Union[str, List[str]], model: str = 'text-embedding-3-small', dimensions: Optional[int] = None, encoding_format: str = 'float', timeout: float = 600.0, api_key: Optional[str] = None, api_base: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> EmbeddingResult</code>
|
||||
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/embedding/embed.py">embedding</a>(input: Union[str, List[str]], model: str = 'text-embedding-3-small', dimensions: Optional[int] = None, encoding_format: str = 'float', timeout: float = 600.0, api_key: Optional[str] = None, api_base: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> EmbeddingResult</code>
|
||||
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/embedding/dimensions.py">get_dimensions</a>(model_name: str) -> int</code>
|
||||
|
||||
# Wrapper (praisonai)
|
||||
|
||||
@@ -240,8 +249,10 @@ Methods:
|
||||
* <code title="cli">praisonai lsp status <a href="./src/praisonai/praisonai/cli/commands/lsp.py">--help</a></code>
|
||||
* <code title="cli">praisonai lsp stop <a href="./src/praisonai/praisonai/cli/commands/lsp.py">--help</a></code>
|
||||
* <code title="cli">praisonai mcp add <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
|
||||
* <code title="cli">praisonai mcp auth <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
|
||||
* <code title="cli">praisonai mcp describe <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
|
||||
* <code title="cli">praisonai mcp list <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
|
||||
* <code title="cli">praisonai mcp logout <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
|
||||
* <code title="cli">praisonai mcp mcp-callback <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
|
||||
* <code title="cli">praisonai mcp remove <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
|
||||
* <code title="cli">praisonai mcp run <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
|
||||
@@ -347,6 +358,8 @@ Types/Exports:
|
||||
```ts
|
||||
export { Agent, Agents, PraisonAIAgents, Router } from "./agent";
|
||||
export type { PraisonAIAgentsConfig, SimpleAgentConfig, SimpleRouteConfig, SimpleRouterConfig } from "./agent";
|
||||
export { AudioAgent, createAudioAgent } from "./agent/audio";
|
||||
export type { AudioAgentConfig, AudioProvider, AudioSpeakOptions, AudioSpeakResult, AudioTranscribeOptions, AudioTranscribeResult } from "./agent/audio";
|
||||
export { ContextAgent, createContextAgent } from "./agent/context";
|
||||
export { Handoff, handoff, handoffFilters } from "./agent/handoff";
|
||||
export { ImageAgent, createImageAgent } from "./agent/image";
|
||||
@@ -368,7 +381,7 @@ export { // Agent loop
|
||||
configureTelemetry, // Tool Approval (AI SDK v6 parity)
|
||||
ApprovalManager, // Tools
|
||||
defineTool, // UI Message (AI SDK v6 parity)
|
||||
convertToModelMessages, AIAgentStep, AIEmbedManyResult, AIEmbedOptions, AIEmbedResult, AIFilePart, AIGenerateImageOptions, AIGenerateImageResult, AIGenerateObjectOptions, AIGenerateObjectResult, AIGenerateTextOptions, AIGenerateTextResult, AIImagePart, AIMiddleware, AIMiddlewareConfig, AIModelMessage, AISpan, AISpanKind, AISpanOptions, AISpanStatus, AIStreamObjectOptions, AIStreamObjectResult, AIStreamTextOptions, AIStreamTextResult, AITelemetryEvent, AITelemetrySettings, AITextPart, AIToolDefinition, AITracer, AgentLoop, DANGEROUS_PATTERNS, MODEL_ALIASES, SPEECH_MODELS, TRANSCRIPTION_MODELS, ToolApprovalDeniedError, ToolApprovalTimeoutError, aiEmbed, aiEmbedMany, aiGenerateImage, aiGenerateObject, aiGenerateText, aiStreamObject, aiStreamText, applyMiddleware, autoEnableDevTools, base64ToUint8Array, clearAICache, clearEvents, closeAllMCPClients, closeMCPClient, convertToUIMessages, createAILoggingMiddleware, createAISpan, createApprovalResponse, createDangerousPatternChecker, createDevToolsMiddleware, createExpressHandler, createFastifyHandler, createFilePart, createHonoHandler, createMultimodalMessage, createNestHandler, createPagesHandler, createPdfPart, createSystemMessage, createTelemetryMiddleware, createTelemetrySettings, createTextMessage, createTextPart, createToolSet, disableAITelemetry, disableDevTools, enableAITelemetry, functionToTool, getAICacheStats, getApprovalManager, getDevToolsState, getDevToolsUrl, getEvents, getMCPClient, getModel, getTelemetrySettings, getToolsNeedingApproval, getTracer, hasModelAlias, hasPendingApprovals, initOpenTelemetry, isDangerous, isDataUrl, isDevToolsEnabled, isTelemetryEnabled, isUrl, listModelAliases, mcpToolsToAITools, parseModel, pipeUIMessageStreamToResponse, recordEvent, resolveModelAlias, safeValidateUIMessages, setApprovalManager, stopAfterSteps, stopWhen, stopWhenNoToolCalls, toMessageContent, toUIMessageStreamResponse, transcribe, uint8ArrayToBase64, validateUIMessages, withApproval, withSpan, wrapModel } from "./ai";
|
||||
convertToModelMessages, AIAgentStep, AIEmbedManyResult, AIEmbedOptions, AIEmbedResult, AIFilePart, AIGenerateImageOptions, AIGenerateImageResult, AIGenerateObjectOptions, AIGenerateObjectResult, AIGenerateTextOptions, AIGenerateTextResult, AIImagePart, AIMiddleware, AIMiddlewareConfig, AIModelMessage, AISpan, AISpanKind, AISpanOptions, AISpanStatus, AIStreamObjectOptions, AIStreamObjectResult, AIStreamTextOptions, AIStreamTextResult, AITelemetryEvent, AITelemetrySettings, AITextPart, AIToolDefinition, AITracer, AgentLoop, DANGEROUS_PATTERNS, MCPClientType, MODEL_ALIASES, SPEECH_MODELS, TRANSCRIPTION_MODELS, ToolApprovalDeniedError, ToolApprovalTimeoutError, aiEmbed, aiEmbedMany, aiGenerateImage, aiGenerateObject, aiGenerateText, aiStreamObject, aiStreamText, applyMiddleware, autoEnableDevTools, base64ToUint8Array, clearAICache, clearEvents, closeAllMCPClients, closeMCPClient, convertToUIMessages, createAILoggingMiddleware, createAISpan, createApprovalResponse, createDangerousPatternChecker, createDevToolsMiddleware, createExpressHandler, createFastifyHandler, createFilePart, createHonoHandler, createMultimodalMessage, createNestHandler, createPagesHandler, createPdfPart, createSystemMessage, createTelemetryMiddleware, createTelemetrySettings, createTextMessage, createTextPart, createToolSet, disableAITelemetry, disableDevTools, enableAITelemetry, functionToTool, getAICacheStats, getApprovalManager, getDevToolsState, getDevToolsUrl, getEvents, getMCPClient, getModel, getTelemetrySettings, getToolsNeedingApproval, getTracer, hasModelAlias, hasPendingApprovals, initOpenTelemetry, isDangerous, isDataUrl, isDevToolsEnabled, isTelemetryEnabled, isUrl, listModelAliases, mcpToolsToAITools, parseModel, pipeUIMessageStreamToResponse, recordEvent, resolveModelAlias, safeValidateUIMessages, setApprovalManager, stopAfterSteps, stopWhen, stopWhenNoToolCalls, toMessageContent, toUIMessageStreamResponse, transcribe, uint8ArrayToBase64, validateUIMessages, withApproval, withSpan, wrapModel } from "./ai";
|
||||
export { AutoAgents, createAutoAgents } from "./auto";
|
||||
export { BaseCache, FileCache, MemoryCache, createFileCache, createMemoryCache } from "./cache";
|
||||
export { CLI_SPEC_VERSION, executeCommand, parseArgs } from "./cli";
|
||||
@@ -392,9 +405,10 @@ export type { DbAdapter, DbConfig, DbMessage, DbRun, DbTrace } from "./db";
|
||||
export { MemoryPostgresAdapter, NeonPostgresAdapter, PostgresSessionStorage, createMemoryPostgres, createNeonPostgres, createPostgresSessionStorage } from "./db/postgres";
|
||||
export { MemoryRedisAdapter, UpstashRedisAdapter, createMemoryRedis, createUpstashRedis } from "./db/redis";
|
||||
export { SQLiteAdapter, createSQLiteAdapter } from "./db/sqlite";
|
||||
export { EvalSuite, accuracyEval, performanceEval, reliabilityEval } from "./eval";
|
||||
export { EvalResults, EvalSuite, Evaluator, accuracyEval, containsKeywordsCriterion, createDefaultEvaluator, createEvalResults, createEvaluator, lengthCriterion, noHarmfulContentCriterion, performanceEval, relevanceCriterion, reliabilityEval } from "./eval";
|
||||
export { AgentEventBus, AgentEvents, EventEmitterPubSub, PubSub, createEventBus, createPubSub } from "./events";
|
||||
export { LLMGuardrail, createLLMGuardrail } from "./guardrails/llm-guardrail";
|
||||
export { DisplayTypes, HooksManager, WorkflowHooksExecutor, clearAllCallbacks, clearApprovalCallback, createHooksManager, createLoggingOperationHooks, createLoggingWorkflowHooks, createTimingWorkflowHooks, createValidationOperationHooks, createWorkflowHooks, executeCallback, executeSyncCallback, getRegisteredDisplayTypes, hasApprovalCallback, registerApprovalCallback, registerDisplayCallback, requestApproval, unregisterDisplayCallback } from "./hooks";
|
||||
export { // Computer Use
|
||||
createComputerUse, ComputerUseClient, createCLIApprovalPrompt, createComputerUseAgent } from "./integrations/computer-use";
|
||||
export { BaseObservabilityProvider, ConsoleObservabilityProvider, LangfuseObservabilityProvider, MemoryObservabilityProvider, ObservabilityTraceContext, createConsoleObservability, createLangfuseObservability, createMemoryObservability } from "./integrations/observability";
|
||||
@@ -412,10 +426,14 @@ export { // Provider classes
|
||||
ProviderRegistry, // Types
|
||||
type LLMProvider, AnthropicProvider, BaseProvider, GoogleProvider, ProviderMessage, ProviderToolDefinition, createProviderRegistry, getAvailableProviders, getDefaultProvider, getDefaultRegistry, hasProvider, isProviderAvailable, listProviders, parseModelString, registerBuiltinProviders, registerProvider, unregisterProvider } from "./llm/providers";
|
||||
export { ADAPTERS, AISDK_PROVIDERS, COMMUNITY_PROVIDERS, PROVIDER_ALIASES } from "./llm/providers/ai-sdk/types";
|
||||
export { MCPClient, MCPSecurity, MCPServer, MCPSessionManager, createApiKeyPolicy, createMCPClient, createMCPSecurity, createMCPServer, createMCPSession, createRateLimitPolicy, getMCPTools } from "./mcp";
|
||||
export { AutoMemory, AutoMemoryKnowledgeBase, AutoMemoryVectorStore, DEFAULT_POLICIES, createAutoMemory, createLLMSummarizer } from "./memory/auto-memory";
|
||||
export { DocsManager, createDocsManager } from "./memory/docs-manager";
|
||||
export { FileMemory, createFileMemory } from "./memory/file-memory";
|
||||
export { MemoryHooks, createEncryptionHooks, createLoggingHooks, createMemoryHooks, createValidationHooks } from "./memory/hooks";
|
||||
export { Memory, createMemory } from "./memory/memory";
|
||||
export type { MemoryConfig, MemoryEntry } from "./memory/memory";
|
||||
export { RulesManager, createRulesManager, createSafetyRules } from "./memory/rules-manager";
|
||||
export { // Adapters
|
||||
NoopObservabilityAdapter, // Constants
|
||||
OBSERVABILITY_TOOLS, // Global adapter management
|
||||
@@ -423,14 +441,17 @@ export { // Adapters
|
||||
type SpanKind, ConsoleObservabilityAdapter, MemoryObservabilityAdapter, clearAdapterCache, createConsoleAdapter, createMemoryAdapter, createObservabilityAdapter, getObservabilityAdapter, getObservabilityToolInfo, hasObservabilityToolEnvVar, listObservabilityTools, noopAdapter, resetObservabilityAdapter, trace } from "./observability";
|
||||
export { Plan, PlanStep, PlanStorage, PlanningAgent, TaskAgent, TodoItem, TodoList, createPlan, createPlanStorage, createPlanningAgent, createTaskAgent, createTodoList } from "./planning";
|
||||
export { SkillManager, createSkillManager, parseSkillFile } from "./skills";
|
||||
export { AgentTelemetry, TelemetryCollector, cleanupTelemetry, createAgentTelemetry, disableTelemetry, enableTelemetry, getTelemetry } from "./telemetry";
|
||||
export { BaseTool, FunctionTool, ToolRegistry, ToolResult, ToolValidationError, createTool, getRegistry, getTool, registerTool, tool, validateTool } from "./tools";
|
||||
export { AgentTelemetry, PerformanceMonitor, TelemetryCollector, TelemetryIntegration, cleanupTelemetry, createAgentTelemetry, createConsoleSink, createHTTPSink, createPerformanceMonitor, createTelemetryIntegration, disableTelemetry, enableTelemetry, getTelemetry } from "./telemetry";
|
||||
export { // Subagent Tool (agent-as-tool pattern)
|
||||
SubagentTool, BaseTool, FunctionTool, ToolRegistry, ToolResult, ToolValidationError, createDelegator, createSubagentTool, createSubagentTools, createTool, getRegistry, getTool, registerTool, tool, validateTool } from "./tools";
|
||||
export { airweaveSearch, bedrockBrowserClick, bedrockBrowserFill, bedrockBrowserNavigate, bedrockCodeInterpreter, codeExecution, codeMode, createCustomTool, exaSearch, firecrawlCrawl, firecrawlScrape, parallelSearch, perplexitySearch, registerCustomTool, registerLocalTool, registerNpmTool, superagentGuard, superagentRedact, superagentVerify, tavilyCrawl, tavilyExtract, tavilySearch, valyuBioSearch, valyuCompanyResearch, valyuEconomicsSearch, valyuFinanceSearch, valyuPaperSearch, valyuPatentSearch, valyuSecSearch, valyuWebSearch } from "./tools/builtins";
|
||||
export { MissingDependencyError, MissingEnvVarError, ToolsRegistry, composeMiddleware, createLoggingMiddleware, createRateLimitMiddleware, createRedactionMiddleware, createRetryMiddleware, createTimeoutMiddleware, createToolsRegistry, createTracingMiddleware, createValidationMiddleware, getToolsRegistry, resetToolsRegistry } from "./tools/registry";
|
||||
export type { InstallHints, PraisonTool, RedactionHooks, RegisteredTool, ToolCapabilities, ToolExecutionContext, ToolExecutionResult, ToolFactory, ToolHooks, ToolInstallStatus, ToolLimits, ToolLogger, ToolMetadata, ToolMiddleware, ToolParameterProperty, ToolParameterSchema } from "./tools/registry";
|
||||
export { registerBuiltinTools, tools } from "./tools/tools";
|
||||
export { Workflow, loop, parallel, repeat, route } from "./workflows";
|
||||
export type { StepResult, WorkflowContext, WorkflowStep } from "./workflows";
|
||||
export { // New: Python-parity Loop and Repeat classes
|
||||
Loop, // WorkflowStep class
|
||||
WorkflowStep, Repeat, Workflow, loop, loopPattern, parallel, repeat, repeatPattern, route } from "./workflows";
|
||||
export type { LoopConfig, LoopResult, RepeatConfig, RepeatContext, RepeatResult, StepContextConfig, StepExecutionConfig, StepOutputConfig, StepResult, StepRoutingConfig, WorkflowContext, WorkflowStepConfig } from "./workflows";
|
||||
export { createWorkflowFromYAML, loadWorkflowFromFile, parseYAMLWorkflow, validateWorkflowDefinition } from "./workflows/yaml-parser";
|
||||
```
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=3.9.4" \
|
||||
"praisonai>=3.10.2" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=3.9.4" \
|
||||
"praisonai>=3.10.2" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=3.9.4" \
|
||||
"praisonai>=3.10.2" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
Embeddings Capability Example
|
||||
|
||||
Demonstrates text embedding generation using PraisonAI capabilities.
|
||||
Both `embed` and `embedding` work identically - use whichever you prefer.
|
||||
"""
|
||||
|
||||
from praisonai.capabilities import embed
|
||||
from praisonai import embed, embedding # Both work from top-level
|
||||
# Or: from praisonai.capabilities import embed, embedding
|
||||
|
||||
# Single text embedding
|
||||
print("=== Single Text Embedding ===")
|
||||
# Single text embedding using embed()
|
||||
print("=== Single Text Embedding (using embed) ===")
|
||||
result = embed(
|
||||
input="Hello, world!",
|
||||
model="text-embedding-3-small"
|
||||
@@ -16,6 +18,14 @@ print(f"Embedding dimensions: {len(result.embeddings[0])}")
|
||||
print(f"First 5 values: {result.embeddings[0][:5]}")
|
||||
print(f"Usage: {result.usage}")
|
||||
|
||||
# Same thing using embedding() alias
|
||||
print("\n=== Single Text Embedding (using embedding alias) ===")
|
||||
result = embedding(
|
||||
input="Hello, world!",
|
||||
model="text-embedding-3-small"
|
||||
)
|
||||
print(f"Embedding dimensions: {len(result.embeddings[0])}")
|
||||
|
||||
# Multiple text embeddings
|
||||
print("\n=== Multiple Text Embeddings ===")
|
||||
result = embed(
|
||||
@@ -25,3 +35,12 @@ result = embed(
|
||||
print(f"Number of embeddings: {len(result.embeddings)}")
|
||||
for i, emb in enumerate(result.embeddings):
|
||||
print(f" Text {i+1}: {len(emb)} dimensions")
|
||||
|
||||
# With custom dimensions (for models that support it)
|
||||
print("\n=== Custom Dimensions ===")
|
||||
result = embed(
|
||||
input="Hello world",
|
||||
model="text-embedding-3-large",
|
||||
dimensions=256
|
||||
)
|
||||
print(f"Reduced dimensions: {len(result.embeddings[0])}")
|
||||
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
Basic Context Management Example
|
||||
|
||||
Demonstrates enabling context management with ContextConfig.
|
||||
This manages token budgets, auto-compaction, and prevents context overflow.
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.context import ContextConfig, OptimizerStrategy
|
||||
|
||||
# Example 1: Enable context with defaults
|
||||
agent_basic = Agent(
|
||||
name="Assistant",
|
||||
instructions="You are a helpful assistant",
|
||||
context=True # Auto-compact at 80% utilization
|
||||
)
|
||||
|
||||
# Example 2: Custom configuration
|
||||
agent_custom = Agent(
|
||||
name="CustomAgent",
|
||||
instructions="You are a helpful assistant with custom context settings",
|
||||
context=ContextConfig(
|
||||
auto_compact=True,
|
||||
compact_threshold=0.8, # Trigger at 80% utilization
|
||||
strategy=OptimizerStrategy.SMART,
|
||||
output_reserve=8000, # Reserve tokens for output
|
||||
keep_recent_turns=5, # Always keep last 5 turns
|
||||
tool_output_max=10000, # Max tokens per tool output
|
||||
)
|
||||
)
|
||||
|
||||
# Example 3: Sliding window strategy
|
||||
agent_sliding = Agent(
|
||||
name="SlidingAgent",
|
||||
instructions="You are a helpful assistant using sliding window",
|
||||
context=ContextConfig(
|
||||
strategy=OptimizerStrategy.SLIDING_WINDOW,
|
||||
keep_recent_turns=10, # Keep last 10 turns
|
||||
)
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=== Basic Context Example ===")
|
||||
print("Context management is automatically enabled.")
|
||||
print()
|
||||
|
||||
# Test basic agent
|
||||
result = agent_basic.chat("Hello! What is 2 + 2?")
|
||||
print(f"Response: {result}")
|
||||
print()
|
||||
|
||||
# Show context config
|
||||
print("=== Context Configuration ===")
|
||||
config = ContextConfig(
|
||||
auto_compact=True,
|
||||
compact_threshold=0.8,
|
||||
strategy=OptimizerStrategy.SMART,
|
||||
)
|
||||
print(f"Config: {config.to_dict()}")
|
||||
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
Session Tracking Example
|
||||
|
||||
Demonstrates tracking conversation state (goal, plan, progress) across turns.
|
||||
Inspired by Agno's SessionContextStore pattern.
|
||||
"""
|
||||
|
||||
from praisonaiagents.context import SessionContextTracker
|
||||
|
||||
# Create session tracker
|
||||
tracker = SessionContextTracker(
|
||||
session_id="user123",
|
||||
track_summary=True,
|
||||
track_goal=True,
|
||||
track_plan=True,
|
||||
track_progress=True,
|
||||
)
|
||||
|
||||
# Set the user's goal
|
||||
tracker.update_goal("Build a Python web application with Flask")
|
||||
|
||||
# Define the plan
|
||||
tracker.update_plan([
|
||||
"Create Flask project structure",
|
||||
"Add routes for API endpoints",
|
||||
"Implement database models",
|
||||
"Add authentication",
|
||||
"Write tests",
|
||||
"Deploy to cloud"
|
||||
])
|
||||
|
||||
# Mark completed steps
|
||||
tracker.add_progress("Created Flask project structure")
|
||||
tracker.add_progress("Added routes for API endpoints")
|
||||
|
||||
# Update summary
|
||||
tracker.update_summary(
|
||||
"User is building a Flask web app. Completed project setup and API routes. "
|
||||
"Next: implement database models."
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=== Session Tracking Example ===")
|
||||
print()
|
||||
|
||||
# Show current state
|
||||
print(f"Session ID: {tracker.session_id}")
|
||||
print(f"Goal: {tracker.goal}")
|
||||
print(f"Plan: {tracker.plan}")
|
||||
print(f"Progress: {tracker.progress}")
|
||||
print()
|
||||
|
||||
# Get context string (simple format)
|
||||
print("=== Context String ===")
|
||||
print(tracker.to_context_string())
|
||||
print()
|
||||
|
||||
# Get system prompt section (XML format with guidelines)
|
||||
print("=== System Prompt Section ===")
|
||||
print(tracker.to_system_prompt_section())
|
||||
print()
|
||||
|
||||
# Export to dict for persistence
|
||||
print("=== Export as Dict ===")
|
||||
data = tracker.to_dict()
|
||||
print(f"Exported: {data.keys()}")
|
||||
print()
|
||||
|
||||
# Recreate from dict
|
||||
new_tracker = SessionContextTracker.from_dict(data)
|
||||
print(f"Restored goal: {new_tracker.goal}")
|
||||
@@ -0,0 +1,81 @@
|
||||
"""
|
||||
Multi-Memory Aggregation Example
|
||||
|
||||
Demonstrates concurrent fetching from multiple context sources.
|
||||
Inspired by CrewAI's ContextualMemory pattern.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from praisonaiagents.context import ContextAggregator, create_aggregator_from_config
|
||||
|
||||
# Example 1: Basic aggregator with mock sources
|
||||
def mock_memory_search(query: str) -> str:
|
||||
"""Mock memory search - returns recent conversation context."""
|
||||
return f"[Memory] User previously asked about: {query}. They prefer Python."
|
||||
|
||||
def mock_knowledge_search(query: str) -> str:
|
||||
"""Mock knowledge search - returns facts from knowledge base."""
|
||||
return f"[Knowledge] Relevant facts: Python is a high-level programming language."
|
||||
|
||||
def mock_rag_retrieve(query: str) -> str:
|
||||
"""Mock RAG retrieval - returns document chunks."""
|
||||
return f"[RAG] Retrieved: Documentation about {query} from the codebase."
|
||||
|
||||
# Create aggregator
|
||||
aggregator = ContextAggregator(
|
||||
max_tokens=4000,
|
||||
separator="\n\n---\n\n",
|
||||
include_source_labels=True
|
||||
)
|
||||
|
||||
# Register sources with priorities (lower = higher priority)
|
||||
aggregator.register_source("memory", mock_memory_search, priority=10)
|
||||
aggregator.register_source("knowledge", mock_knowledge_search, priority=20)
|
||||
aggregator.register_source("rag", mock_rag_retrieve, priority=30)
|
||||
|
||||
async def demo_async():
|
||||
"""Demo async aggregation."""
|
||||
print("=== Async Aggregation ===")
|
||||
result = await aggregator.aggregate("Python web development")
|
||||
print(f"Sources used: {result.sources_used}")
|
||||
print(f"Tokens used: {result.tokens_used}")
|
||||
print(f"Fetch times: {result.fetch_times}")
|
||||
print()
|
||||
print("Context:")
|
||||
print(result.context)
|
||||
return result
|
||||
|
||||
def demo_sync():
|
||||
"""Demo sync aggregation."""
|
||||
print("=== Sync Aggregation ===")
|
||||
result = aggregator.aggregate_sync("Flask API development")
|
||||
print(f"Sources used: {result.sources_used}")
|
||||
print(f"Tokens used: {result.tokens_used}")
|
||||
print()
|
||||
print("Context:")
|
||||
print(result.context)
|
||||
return result
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=== Multi-Memory Aggregation Example ===")
|
||||
print()
|
||||
|
||||
# Show registered sources
|
||||
print(f"Registered sources: {aggregator.sources}")
|
||||
print()
|
||||
|
||||
# Demo sync aggregation
|
||||
demo_sync()
|
||||
print()
|
||||
|
||||
# Demo async aggregation
|
||||
asyncio.run(demo_async())
|
||||
print()
|
||||
|
||||
# Demo selective sources
|
||||
print("=== Selective Sources ===")
|
||||
result = aggregator.aggregate_sync(
|
||||
"Find database info",
|
||||
sources=["memory", "knowledge"] # Only use these
|
||||
)
|
||||
print(f"Sources used: {result.sources_used}")
|
||||
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
Compaction Strategies Example
|
||||
|
||||
Demonstrates different context compaction strategies.
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.context import ContextConfig, OptimizerStrategy
|
||||
|
||||
# Strategy 1: TRUNCATE - Fast, removes oldest messages
|
||||
agent_truncate = Agent(
|
||||
name="TruncateAgent",
|
||||
instructions="You are a helpful assistant",
|
||||
context=ContextConfig(
|
||||
strategy=OptimizerStrategy.TRUNCATE,
|
||||
compact_threshold=0.8,
|
||||
)
|
||||
)
|
||||
|
||||
# Strategy 2: SLIDING_WINDOW - Keeps recent N turns
|
||||
agent_sliding = Agent(
|
||||
name="SlidingAgent",
|
||||
instructions="You are a helpful assistant",
|
||||
context=ContextConfig(
|
||||
strategy=OptimizerStrategy.SLIDING_WINDOW,
|
||||
keep_recent_turns=10,
|
||||
)
|
||||
)
|
||||
|
||||
# Strategy 3: SMART - Importance-based (default)
|
||||
agent_smart = Agent(
|
||||
name="SmartAgent",
|
||||
instructions="You are a helpful assistant",
|
||||
context=ContextConfig(
|
||||
strategy=OptimizerStrategy.SMART, # Default
|
||||
compact_threshold=0.8,
|
||||
)
|
||||
)
|
||||
|
||||
# Strategy 4: PRUNE_TOOLS - Truncate old tool outputs
|
||||
agent_prune = Agent(
|
||||
name="PruneAgent",
|
||||
instructions="You are a helpful assistant with tools",
|
||||
context=ContextConfig(
|
||||
strategy=OptimizerStrategy.PRUNE_TOOLS,
|
||||
tool_output_max=5000, # Max tokens per tool output
|
||||
)
|
||||
)
|
||||
|
||||
# Strategy 5: NON_DESTRUCTIVE - Only prune tools, keep all history
|
||||
agent_safe = Agent(
|
||||
name="SafeAgent",
|
||||
instructions="You are a helpful assistant (safety-critical)",
|
||||
context=ContextConfig(
|
||||
strategy=OptimizerStrategy.NON_DESTRUCTIVE,
|
||||
)
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=== Compaction Strategies Example ===")
|
||||
print()
|
||||
|
||||
strategies = [
|
||||
("TRUNCATE", OptimizerStrategy.TRUNCATE, "Remove oldest messages"),
|
||||
("SLIDING_WINDOW", OptimizerStrategy.SLIDING_WINDOW, "Keep last N turns"),
|
||||
("SMART", OptimizerStrategy.SMART, "Importance-based (default)"),
|
||||
("PRUNE_TOOLS", OptimizerStrategy.PRUNE_TOOLS, "Truncate tool outputs"),
|
||||
("NON_DESTRUCTIVE", OptimizerStrategy.NON_DESTRUCTIVE, "Only prune tools"),
|
||||
]
|
||||
|
||||
for name, strategy, description in strategies:
|
||||
print(f" {name:20} - {description}")
|
||||
|
||||
print()
|
||||
print("Usage:")
|
||||
print(" context=ContextConfig(strategy=OptimizerStrategy.SLIDING_WINDOW)")
|
||||
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
Context Monitoring Example
|
||||
|
||||
Demonstrates monitoring context usage with logging and metrics.
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.context import ContextConfig, MonitorConfig, OptimizerStrategy
|
||||
|
||||
# Example 1: Human-readable monitoring
|
||||
agent_human = Agent(
|
||||
name="MonitoredAgent",
|
||||
instructions="You are a helpful assistant",
|
||||
context=ContextConfig(
|
||||
auto_compact=True,
|
||||
monitor=MonitorConfig(
|
||||
enabled=True,
|
||||
path="./context_logs/",
|
||||
format="human", # Human-readable format
|
||||
frequency="turn", # Log on every turn
|
||||
redact_sensitive=True, # Redact sensitive data
|
||||
multi_agent_files=True, # Separate files per agent
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Example 2: JSON monitoring (for parsing)
|
||||
agent_json = Agent(
|
||||
name="JSONMonitorAgent",
|
||||
instructions="You are a helpful assistant",
|
||||
context=ContextConfig(
|
||||
monitor=MonitorConfig(
|
||||
enabled=True,
|
||||
path="./context_logs/",
|
||||
format="json", # JSON format for parsing
|
||||
frequency="tool_call", # Log on tool calls
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Example 3: Overflow-only monitoring
|
||||
agent_overflow = Agent(
|
||||
name="OverflowAgent",
|
||||
instructions="You are a helpful assistant",
|
||||
context=ContextConfig(
|
||||
monitor=MonitorConfig(
|
||||
enabled=True,
|
||||
path="./context_logs/",
|
||||
format="human",
|
||||
frequency="overflow", # Only log on overflow
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=== Context Monitoring Example ===")
|
||||
print()
|
||||
|
||||
# Show monitor configuration
|
||||
config = MonitorConfig(
|
||||
enabled=True,
|
||||
path="./context_logs/",
|
||||
format="human",
|
||||
frequency="turn",
|
||||
redact_sensitive=True,
|
||||
)
|
||||
|
||||
print("Monitor Config:")
|
||||
print(f" enabled: {config.enabled}")
|
||||
print(f" path: {config.path}")
|
||||
print(f" format: {config.format}")
|
||||
print(f" frequency: {config.frequency}")
|
||||
print(f" redact_sensitive: {config.redact_sensitive}")
|
||||
print()
|
||||
|
||||
print("Frequency options:")
|
||||
print(" 'turn' - Log on every conversation turn")
|
||||
print(" 'tool_call' - Log when tools are called")
|
||||
print(" 'manual' - Manual logging only")
|
||||
print(" 'overflow' - Log only when context overflows")
|
||||
print()
|
||||
|
||||
print("Format options:")
|
||||
print(" 'human' - Human-readable format")
|
||||
print(" 'json' - JSON format for parsing")
|
||||
@@ -0,0 +1,274 @@
|
||||
"""
|
||||
Example 15: Recursive Context Pattern
|
||||
|
||||
Demonstrates the RLM (Recursive Language Model) pattern for handling large context:
|
||||
- Store large documents as artifacts (not in LLM context)
|
||||
- Agent uses tools (grep/head/tail/chunk) to explore artifacts
|
||||
- Dramatically reduces token usage (~2K vs ~10K+)
|
||||
|
||||
This pattern solves "context rot" by treating context as programmatically
|
||||
explorable data instead of dumping everything into the LLM's context window.
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
import os
|
||||
from praisonaiagents import Agent
|
||||
from praisonai.context import FileSystemArtifactStore
|
||||
from praisonai.context.queue import create_artifact_tools
|
||||
from praisonaiagents.context.artifacts import ArtifactMetadata
|
||||
|
||||
|
||||
def create_sample_document():
|
||||
"""Create a sample large document for testing."""
|
||||
# Simulate a research paper / large document
|
||||
content = """
|
||||
# Attention Is All You Need
|
||||
|
||||
## Abstract
|
||||
|
||||
The dominant sequence transduction models are based on complex recurrent or
|
||||
convolutional neural networks that include an encoder and a decoder. The best
|
||||
performing models also connect the encoder and decoder through an attention
|
||||
mechanism. We propose a new simple network architecture, the Transformer,
|
||||
based solely on attention mechanisms, dispensing with recurrence and convolutions
|
||||
entirely.
|
||||
|
||||
## Introduction
|
||||
|
||||
Recurrent neural networks, long short-term memory and gated recurrent neural
|
||||
networks in particular, have been firmly established as state of the art
|
||||
approaches in sequence modeling and transduction problems such as language
|
||||
modeling and machine translation.
|
||||
|
||||
## Background
|
||||
|
||||
The goal of reducing sequential computation also forms the foundation of the
|
||||
Extended Neural GPU, ByteNet and ConvS2S, all of which use convolutional neural
|
||||
networks as basic building block.
|
||||
|
||||
## Model Architecture
|
||||
|
||||
### Encoder and Decoder Stacks
|
||||
|
||||
The encoder is composed of a stack of N = 6 identical layers. Each layer has
|
||||
two sub-layers. The first is a multi-head self-attention mechanism, and the
|
||||
second is a simple, position-wise fully connected feed-forward network.
|
||||
|
||||
### Attention
|
||||
|
||||
An attention function can be described as mapping a query and a set of key-value
|
||||
pairs to an output, where the query, keys, values, and output are all vectors.
|
||||
|
||||
#### Scaled Dot-Product Attention
|
||||
|
||||
We call our particular attention "Scaled Dot-Product Attention". The input
|
||||
consists of queries and keys of dimension dk, and values of dimension dv.
|
||||
|
||||
The formula is: Attention(Q, K, V) = softmax(QK^T / sqrt(d_k))V
|
||||
|
||||
### Multi-Head Attention
|
||||
|
||||
Multi-head attention allows the model to jointly attend to information from
|
||||
different representation subspaces at different positions.
|
||||
|
||||
## Results
|
||||
|
||||
### Machine Translation
|
||||
|
||||
On the WMT 2014 English-to-German translation task, the big transformer model
|
||||
outperforms the best previously reported models including ensembles.
|
||||
|
||||
The model achieved a BLEU score of 28.4 on the WMT 2014 English-to-German
|
||||
translation task, improving over the existing best results by over 2 BLEU.
|
||||
|
||||
On the WMT 2014 English-to-French translation task, our big model achieves
|
||||
a BLEU score of 41.8, outperforming all published single models.
|
||||
|
||||
### Training
|
||||
|
||||
Training took 3.5 days on 8 P100 GPUs. The big model was trained for 300,000
|
||||
steps (3.5 days) at a cost of approximately $1000 in cloud compute.
|
||||
|
||||
## Model Variations
|
||||
|
||||
| Model | Parameters | BLEU EN-DE | BLEU EN-FR |
|
||||
|-------|------------|------------|------------|
|
||||
| Base | 65M | 27.3 | 38.1 |
|
||||
| Big | 213M | 28.4 | 41.8 |
|
||||
|
||||
The base model has 65 million parameters (65 × 10^6).
|
||||
The big model has 213 million parameters.
|
||||
|
||||
## Conclusion
|
||||
|
||||
In this work, we presented the Transformer, the first sequence transduction
|
||||
model based entirely on attention, replacing the recurrent layers most commonly
|
||||
used in encoder-decoder architectures with multi-headed self-attention.
|
||||
|
||||
The Transformer can be trained significantly faster than architectures based
|
||||
on recurrent or convolutional layers.
|
||||
|
||||
## Authors
|
||||
|
||||
Ashish Vaswani (Google Brain)
|
||||
Noam Shazeer (Google Brain)
|
||||
Niki Parmar (Google Research)
|
||||
Jakob Uszkoreit (Google Research)
|
||||
Llion Jones (Google Research)
|
||||
Aidan N. Gomez (University of Toronto)
|
||||
Łukasz Kaiser (Google Brain)
|
||||
Illia Polosukhin
|
||||
|
||||
## References
|
||||
|
||||
[1] Neural Machine Translation by Jointly Learning to Align and Translate
|
||||
[2] Sequence to Sequence Learning with Neural Networks
|
||||
[3] Learning Phrase Representations using RNN Encoder-Decoder
|
||||
"""
|
||||
# Repeat content to make it larger
|
||||
full_content = (content + "\n\n") * 10 # ~35KB
|
||||
return full_content
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 70)
|
||||
print("Recursive Context Pattern - Token Efficiency Demo")
|
||||
print("=" * 70)
|
||||
|
||||
# Track token usage across methods
|
||||
token_stats = {
|
||||
"traditional": {"input": 0, "output": 0},
|
||||
"recursive": {"input": 0, "output": 0},
|
||||
}
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# 1. Create artifact store and tools
|
||||
store = FileSystemArtifactStore(base_dir=tmpdir)
|
||||
artifact_tools = create_artifact_tools(store=store)
|
||||
|
||||
print(f"\n1. Created FileSystemArtifactStore")
|
||||
print(f" Location: {tmpdir}")
|
||||
print(f" Tools available: {[t.__name__ for t in artifact_tools]}")
|
||||
|
||||
# 2. Create sample document and store as artifact
|
||||
document = create_sample_document()
|
||||
doc_size = len(document)
|
||||
doc_tokens = doc_size // 4 # Rough estimate: 4 chars per token
|
||||
|
||||
metadata = ArtifactMetadata(
|
||||
agent_id="research_agent",
|
||||
run_id="demo_run",
|
||||
tool_name="document_loader",
|
||||
turn_id=1
|
||||
)
|
||||
|
||||
ref = store.store(document, metadata)
|
||||
|
||||
print(f"\n2. Stored document as artifact")
|
||||
print(f" Size: {doc_size:,} chars (~{doc_tokens:,} tokens)")
|
||||
print(f" Path: {ref.path}")
|
||||
print(f" Summary: {ref.summary[:80]}...")
|
||||
|
||||
# 3. Create agent WITH artifact tools (Recursive Context approach)
|
||||
agent = Agent(
|
||||
instructions="""You are a research paper analyst with access to artifact exploration tools.
|
||||
When given an artifact path, use the tools to explore it efficiently:
|
||||
- artifact_grep: Search for patterns (USE THIS FIRST)
|
||||
- artifact_head: See first N lines
|
||||
- artifact_tail: See last N lines
|
||||
- artifact_chunk: Get specific line ranges
|
||||
|
||||
Be precise and cite line numbers when finding information.""",
|
||||
tools=artifact_tools,
|
||||
output='silent' # Quiet output for cleaner demo
|
||||
)
|
||||
|
||||
print(f"\n3. Created agent with {len(agent.tools)} artifact tools")
|
||||
|
||||
# 4. Query using RECURSIVE CONTEXT (small prompt + tools)
|
||||
print(f"\n4. RECURSIVE CONTEXT APPROACH")
|
||||
print("-" * 40)
|
||||
|
||||
prompt = f"""I have a research paper stored at: {ref.path}
|
||||
|
||||
Find the BLEU score on the WMT 2014 English-to-German translation task.
|
||||
Use artifact_grep to search, then report the answer with line number."""
|
||||
|
||||
prompt_tokens = len(prompt) // 4
|
||||
print(f" Prompt size: {len(prompt)} chars (~{prompt_tokens} tokens)")
|
||||
print(f" Document NOT in prompt context!")
|
||||
|
||||
response = agent.chat(prompt)
|
||||
|
||||
# Estimate output tokens (could use actual LiteLLM response if available)
|
||||
output_tokens = len(response) // 4
|
||||
|
||||
# Calculate recursive approach tokens (prompt + tool calls + response)
|
||||
# Tool call: ~200 tokens for tool desc + call
|
||||
# Tool response: ~300 tokens for grep results
|
||||
estimated_total = prompt_tokens + 200 + 300 + output_tokens
|
||||
token_stats["recursive"]["input"] = estimated_total
|
||||
|
||||
print(f"\n Agent Response:")
|
||||
print(f" {response[:200]}...")
|
||||
print(f"\n Estimated tokens used: ~{estimated_total:,}")
|
||||
|
||||
# 5. Compare with TRADITIONAL approach (full context)
|
||||
print(f"\n5. TRADITIONAL APPROACH (for comparison)")
|
||||
print("-" * 40)
|
||||
|
||||
traditional_prompt_tokens = prompt_tokens + doc_tokens
|
||||
token_stats["traditional"]["input"] = traditional_prompt_tokens
|
||||
|
||||
print(f" If we passed FULL document in prompt:")
|
||||
print(f" Prompt: {prompt_tokens} + Document: {doc_tokens} = {traditional_prompt_tokens:,} tokens")
|
||||
|
||||
# 6. Show token savings
|
||||
print(f"\n6. TOKEN COMPARISON")
|
||||
print("=" * 40)
|
||||
|
||||
savings = token_stats["traditional"]["input"] - token_stats["recursive"]["input"]
|
||||
savings_pct = (savings / token_stats["traditional"]["input"]) * 100
|
||||
|
||||
print(f" Traditional approach: ~{token_stats['traditional']['input']:,} tokens")
|
||||
print(f" Recursive approach: ~{token_stats['recursive']['input']:,} tokens")
|
||||
print(f" -----------------------------------")
|
||||
print(f" SAVINGS: ~{savings:,} tokens ({savings_pct:.0f}%)")
|
||||
|
||||
# 7. Demonstrate other artifact operations
|
||||
print(f"\n7. ARTIFACT OPERATIONS DEMO")
|
||||
print("-" * 40)
|
||||
|
||||
# head
|
||||
head_result = store.head(ref, lines=5)
|
||||
print(f" head(5 lines):")
|
||||
for line in head_result.split('\n')[:3]:
|
||||
print(f" {line[:60]}")
|
||||
|
||||
# grep
|
||||
grep_results = store.grep(ref, pattern=r"BLEU.*\d+", max_matches=3)
|
||||
print(f"\n grep('BLEU.*\\d+'):")
|
||||
for match in grep_results[:2]:
|
||||
print(f" Line {match.line_number}: {match.line_content[:50].strip()}...")
|
||||
|
||||
# chunk
|
||||
chunk_result = store.chunk(ref, start_line=50, end_line=55)
|
||||
print(f"\n chunk(lines 50-55):")
|
||||
for line in chunk_result.split('\n')[:3]:
|
||||
print(f" {line[:60]}")
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("CONCLUSION: Recursive Context Pattern")
|
||||
print("=" * 70)
|
||||
print("""
|
||||
The Recursive Context pattern (RLM) provides:
|
||||
✓ Token efficiency: ~{savings_pct:.0f}% reduction in token usage
|
||||
✓ Scalability: Handle documents of any size
|
||||
✓ Precision: Agent searches for exactly what it needs
|
||||
✓ Cost savings: Lower API costs due to fewer tokens
|
||||
✓ Better accuracy: Avoids "context rot" from large contexts
|
||||
""".format(savings_pct=savings_pct))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
Agent-centric Embedding Example
|
||||
|
||||
This example shows how to use embeddings with PraisonAI Agents.
|
||||
Embeddings are used internally by Agent for:
|
||||
- Knowledge retrieval (RAG)
|
||||
- Memory storage and search
|
||||
- Semantic similarity
|
||||
|
||||
You can also use the embedding API directly for custom use cases.
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, embedding
|
||||
|
||||
# =============================================================================
|
||||
# Example 1: Direct Embedding API
|
||||
# =============================================================================
|
||||
|
||||
def example_direct_embedding():
|
||||
"""Use the embedding API directly."""
|
||||
|
||||
# Single text embedding
|
||||
result = embedding("Hello, world!")
|
||||
print(f"Single embedding: {len(result.embeddings[0])} dimensions")
|
||||
|
||||
# Batch embedding
|
||||
texts = ["AI agents are powerful", "Machine learning is fascinating"]
|
||||
result = embedding(texts, model="text-embedding-3-small")
|
||||
print(f"Batch embeddings: {len(result.embeddings)} vectors")
|
||||
|
||||
# Custom dimensions (for models that support it)
|
||||
result = embedding("Hello", model="text-embedding-3-large", dimensions=256)
|
||||
print(f"Custom dimensions: {len(result.embeddings[0])} dimensions")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Example 2: Agent with Knowledge (uses embeddings internally)
|
||||
# =============================================================================
|
||||
|
||||
def example_agent_with_knowledge():
|
||||
"""Agent with knowledge uses embeddings for RAG."""
|
||||
|
||||
agent = Agent(
|
||||
instructions="You are a helpful assistant with knowledge about AI.",
|
||||
knowledge=["AI agents can use tools to accomplish tasks."],
|
||||
knowledge_config={
|
||||
"embedder": {
|
||||
"provider": "openai",
|
||||
"config": {"model": "text-embedding-3-small"}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# The agent uses embeddings internally for knowledge retrieval
|
||||
response = agent.chat("What can AI agents do?")
|
||||
print(f"Agent response: {response}")
|
||||
|
||||
return agent
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Example 3: Agent with Memory (uses embeddings internally)
|
||||
# =============================================================================
|
||||
|
||||
def example_agent_with_memory():
|
||||
"""Agent with memory uses embeddings for semantic search."""
|
||||
|
||||
agent = Agent(
|
||||
instructions="You are a helpful assistant that remembers conversations.",
|
||||
memory=True,
|
||||
memory_config={
|
||||
"embedding_model": "text-embedding-3-small"
|
||||
}
|
||||
)
|
||||
|
||||
# The agent uses embeddings internally for memory storage/retrieval
|
||||
agent.chat("My name is Alice and I love Python programming.")
|
||||
response = agent.chat("What do I love?")
|
||||
print(f"Agent remembered: {response}")
|
||||
|
||||
return agent
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Example 4: Custom Embedding for Similarity Search
|
||||
# =============================================================================
|
||||
|
||||
def example_similarity_search():
|
||||
"""Use embeddings for custom similarity search."""
|
||||
import math
|
||||
|
||||
def cosine_similarity(a, b):
|
||||
dot = sum(x * y for x, y in zip(a, b))
|
||||
norm_a = math.sqrt(sum(x * x for x in a))
|
||||
norm_b = math.sqrt(sum(x * x for x in b))
|
||||
return dot / (norm_a * norm_b) if norm_a and norm_b else 0
|
||||
|
||||
# Embed documents
|
||||
documents = [
|
||||
"Python is a programming language",
|
||||
"JavaScript runs in browsers",
|
||||
"AI agents can automate tasks"
|
||||
]
|
||||
doc_embeddings = embedding(documents)
|
||||
|
||||
# Embed query
|
||||
query = "What language is used for web development?"
|
||||
query_embedding = embedding(query)
|
||||
|
||||
# Find most similar document
|
||||
similarities = [
|
||||
cosine_similarity(query_embedding.embeddings[0], doc_emb)
|
||||
for doc_emb in doc_embeddings.embeddings
|
||||
]
|
||||
|
||||
best_idx = similarities.index(max(similarities))
|
||||
print(f"Most similar document: {documents[best_idx]}")
|
||||
print(f"Similarity score: {similarities[best_idx]:.4f}")
|
||||
|
||||
return similarities
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=" * 60)
|
||||
print("Example 1: Direct Embedding API")
|
||||
print("=" * 60)
|
||||
example_direct_embedding()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Example 4: Custom Similarity Search")
|
||||
print("=" * 60)
|
||||
example_similarity_search()
|
||||
|
||||
# Uncomment to run agent examples (requires API key)
|
||||
# print("\n" + "=" * 60)
|
||||
# print("Example 2: Agent with Knowledge")
|
||||
# print("=" * 60)
|
||||
# example_agent_with_knowledge()
|
||||
|
||||
# print("\n" + "=" * 60)
|
||||
# print("Example 3: Agent with Memory")
|
||||
# print("=" * 60)
|
||||
# example_agent_with_memory()
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* AudioAgent Integration Test
|
||||
*
|
||||
* Tests the AudioAgent for TTS and STT using AI SDK.
|
||||
* Requires OPENAI_API_KEY for live tests.
|
||||
*
|
||||
* Run: npx ts-node audio-agent.ts
|
||||
*/
|
||||
|
||||
import { AudioAgent, createAudioAgent } from '../../../src/praisonai-ts/dist';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
async function main() {
|
||||
console.log('=== AudioAgent Integration Test ===\n');
|
||||
|
||||
// Test 1: Create AudioAgent with defaults
|
||||
console.log('1. Testing AudioAgent creation:');
|
||||
const agent = new AudioAgent({
|
||||
name: 'TestAudioAgent',
|
||||
provider: 'openai',
|
||||
voice: 'alloy',
|
||||
verbose: true
|
||||
});
|
||||
console.log(' Agent ID:', agent.id);
|
||||
console.log(' Agent Name:', agent.name);
|
||||
console.log(' Success: ✅');
|
||||
|
||||
// Test 2: Factory function
|
||||
console.log('\n2. Testing createAudioAgent() factory:');
|
||||
const agent2 = createAudioAgent({ provider: 'openai', voice: 'nova' });
|
||||
console.log(' Agent created:', typeof agent2);
|
||||
console.log(' Success: ✅');
|
||||
|
||||
// Test 3: Live TTS (requires API key)
|
||||
if (process.env.OPENAI_API_KEY) {
|
||||
console.log('\n3. Testing Text-to-Speech (live API):');
|
||||
try {
|
||||
const ttsAgent = new AudioAgent({
|
||||
provider: 'openai',
|
||||
voice: 'alloy',
|
||||
verbose: false
|
||||
});
|
||||
|
||||
const result = await ttsAgent.speak('Hello from PraisonAI AudioAgent!');
|
||||
console.log(' Audio format:', result.format);
|
||||
console.log(' Audio size:', result.audio.byteLength || result.audio.length, 'bytes');
|
||||
|
||||
// Optionally save to file
|
||||
const outputPath = path.join(__dirname, 'test-output.mp3');
|
||||
if (result.audio instanceof Buffer) {
|
||||
fs.writeFileSync(outputPath, result.audio);
|
||||
console.log(' Saved to:', outputPath);
|
||||
}
|
||||
console.log(' Success: ✅');
|
||||
} catch (error: any) {
|
||||
console.log(' ⚠️ TTS failed:', error.message);
|
||||
}
|
||||
|
||||
// Test 4: Live Transcription (requires audio file)
|
||||
console.log('\n4. Testing Speech-to-Text (live API):');
|
||||
try {
|
||||
const sttAgent = new AudioAgent({
|
||||
provider: 'openai',
|
||||
verbose: false
|
||||
});
|
||||
|
||||
// Check if we have the test output from TTS
|
||||
const audioPath = path.join(__dirname, 'test-output.mp3');
|
||||
if (fs.existsSync(audioPath)) {
|
||||
const result = await sttAgent.transcribe(audioPath);
|
||||
console.log(' Transcribed text:', result.text);
|
||||
console.log(' Language:', result.language || 'auto-detected');
|
||||
console.log(' Success: ✅');
|
||||
|
||||
// Cleanup
|
||||
fs.unlinkSync(audioPath);
|
||||
console.log(' Cleaned up test file');
|
||||
} else {
|
||||
console.log(' Skipped (no test audio file available)');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.log(' ⚠️ STT failed:', error.message);
|
||||
}
|
||||
|
||||
// Test 5: Chat method (auto-detect mode)
|
||||
console.log('\n5. Testing chat() method:');
|
||||
try {
|
||||
const chatAgent = new AudioAgent({ provider: 'openai', voice: 'echo' });
|
||||
|
||||
// Text input -> TTS
|
||||
const chatResult = await chatAgent.chat('Testing audio chat interface');
|
||||
console.log(' Chat result:', chatResult);
|
||||
console.log(' Success: ✅');
|
||||
} catch (error: any) {
|
||||
console.log(' ⚠️ Chat failed:', error.message);
|
||||
}
|
||||
|
||||
} else {
|
||||
console.log('\n3-5. Live API Tests: Skipped (OPENAI_API_KEY not set)');
|
||||
console.log(' Set OPENAI_API_KEY to run live tests');
|
||||
}
|
||||
|
||||
// Test 6: Different providers (just creation, no live calls)
|
||||
console.log('\n6. Testing provider configurations:');
|
||||
const providers = ['openai', 'elevenlabs', 'groq', 'deepgram'] as const;
|
||||
|
||||
for (const provider of providers) {
|
||||
try {
|
||||
const a = new AudioAgent({ provider });
|
||||
console.log(` ${provider}: ✅ (created)`);
|
||||
} catch (error: any) {
|
||||
console.log(` ${provider}: ⚠️ ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Test 7: Voice options
|
||||
console.log('\n7. Testing OpenAI voice options:');
|
||||
const voices = ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'];
|
||||
for (const voice of voices) {
|
||||
const a = new AudioAgent({ provider: 'openai', voice });
|
||||
console.log(` ${voice}: ✅`);
|
||||
}
|
||||
|
||||
console.log('\n=== AudioAgent Tests Complete ===');
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* ChonkieAdapter Integration Test
|
||||
*
|
||||
* Tests the ChonkieJS adapter for text chunking.
|
||||
* Works without ChonkieJS installed (uses native fallback).
|
||||
*
|
||||
* Run: npx ts-node chonkie-adapter.ts
|
||||
*/
|
||||
|
||||
import {
|
||||
ChonkieAdapter,
|
||||
createChonkieAdapter,
|
||||
hasChonkie,
|
||||
Chunking
|
||||
} from '../../../src/praisonai-ts/dist';
|
||||
|
||||
async function main() {
|
||||
console.log('=== ChonkieAdapter Integration Test ===\n');
|
||||
|
||||
// Test 1: Check ChonkieJS availability
|
||||
console.log('1. Checking ChonkieJS availability:');
|
||||
const chonkieAvailable = await hasChonkie();
|
||||
console.log(` ChonkieJS installed: ${chonkieAvailable ? '✅' : '❌ (using native fallback)'}`);
|
||||
|
||||
// Test 2: Create adapter with default config
|
||||
console.log('\n2. Testing ChonkieAdapter creation:');
|
||||
const adapter = new ChonkieAdapter({
|
||||
strategy: 'token',
|
||||
chunkSize: 100,
|
||||
overlap: 20
|
||||
});
|
||||
console.log(' Adapter created: ✅');
|
||||
console.log(' Info:', adapter.getInfo());
|
||||
|
||||
// Test 3: Chunk text
|
||||
console.log('\n3. Testing text chunking:');
|
||||
const sampleText = `
|
||||
PraisonAI is a high-performance, agentic AI framework designed with minimal client-side API.
|
||||
It features extreme performance sensitivity, lazy loading everywhere, and optional dependencies only.
|
||||
The framework supports clean separation between Core SDK, Wrapper/integrations, and Tools/plugins.
|
||||
All features must have a relevant CLI integration and representation.
|
||||
`.trim();
|
||||
|
||||
const chunks = await adapter.chunk(sampleText);
|
||||
console.log(` Input length: ${sampleText.length} characters`);
|
||||
console.log(` Chunks created: ${chunks.length}`);
|
||||
chunks.forEach((chunk, i) => {
|
||||
console.log(` [${i}]: "${chunk.content.slice(0, 50)}..." (${chunk.content.length} chars)`);
|
||||
});
|
||||
console.log(' Success: ✅');
|
||||
|
||||
// Test 4: Different strategies
|
||||
console.log('\n4. Testing different chunking strategies:');
|
||||
const strategies = ['size', 'sentence', 'paragraph', 'semantic'] as const;
|
||||
|
||||
for (const strategy of strategies) {
|
||||
const strategyAdapter = createChonkieAdapter({ strategy, chunkSize: 80 });
|
||||
const strategyChunks = await strategyAdapter.chunk(sampleText);
|
||||
console.log(` ${strategy}: ${strategyChunks.length} chunks`);
|
||||
}
|
||||
console.log(' Success: ✅');
|
||||
|
||||
// Test 5: Native Chunking class
|
||||
console.log('\n5. Testing native Chunking class:');
|
||||
const nativeChunker = new Chunking({ chunkSize: 100, overlap: 10, strategy: 'sentence' });
|
||||
const nativeChunks = nativeChunker.chunk(sampleText);
|
||||
console.log(` Native chunks: ${nativeChunks.length}`);
|
||||
console.log(' Success: ✅');
|
||||
|
||||
// Test 6: Factory function
|
||||
console.log('\n6. Testing createChunking() factory:');
|
||||
const factoryChunker = createChunking({ chunkSize: 200 });
|
||||
const factoryChunks = factoryChunker.chunk(sampleText);
|
||||
console.log(` Factory chunks: ${factoryChunks.length}`);
|
||||
console.log(' Success: ✅');
|
||||
|
||||
// Test 7: Merge small chunks
|
||||
console.log('\n7. Testing mergeSmallChunks:');
|
||||
const tinyChunker = new Chunking({ chunkSize: 20, overlap: 5 });
|
||||
const tinyChunks = tinyChunker.chunk(sampleText);
|
||||
const mergedChunks = tinyChunker.mergeSmallChunks(tinyChunks, 50);
|
||||
console.log(` Before merge: ${tinyChunks.length} chunks`);
|
||||
console.log(` After merge: ${mergedChunks.length} chunks`);
|
||||
console.log(' Success: ✅');
|
||||
|
||||
// Test 8: Code chunking (if ChonkieJS available)
|
||||
if (chonkieAvailable) {
|
||||
console.log('\n8. Testing code chunking (ChonkieJS):');
|
||||
const codeAdapter = createChonkieAdapter({ strategy: 'code', language: 'typescript' });
|
||||
const codeText = `
|
||||
function hello() {
|
||||
console.log('Hello');
|
||||
}
|
||||
|
||||
class MyClass {
|
||||
constructor() {
|
||||
this.value = 42;
|
||||
}
|
||||
|
||||
getValue() {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
`.trim();
|
||||
|
||||
const codeChunks = await codeAdapter.chunk(codeText);
|
||||
console.log(` Code chunks: ${codeChunks.length}`);
|
||||
console.log(' Success: ✅');
|
||||
} else {
|
||||
console.log('\n8. Code chunking: Skipped (ChonkieJS not installed)');
|
||||
}
|
||||
|
||||
// Test 9: Verify adapter info
|
||||
console.log('\n9. Verifying adapter state:');
|
||||
const info = adapter.getInfo();
|
||||
console.log(` Strategy: ${info.strategy}`);
|
||||
console.log(` Using ChonkieJS: ${info.usingChonkie}`);
|
||||
console.log(` Chunk size: ${info.config.chunkSize}`);
|
||||
console.log(' Success: ✅');
|
||||
|
||||
console.log('\n=== ChonkieAdapter Tests Complete ===');
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* QueryEngine Integration Test
|
||||
*
|
||||
* Tests semantic and keyword search for RAG applications.
|
||||
*
|
||||
* Run: npx ts-node query-engine.ts
|
||||
*/
|
||||
|
||||
import {
|
||||
QueryEngine,
|
||||
createQueryEngine,
|
||||
createSimpleQueryEngine
|
||||
} from '../../../src/praisonai-ts/dist';
|
||||
|
||||
async function main() {
|
||||
console.log('=== QueryEngine Integration Test ===\n');
|
||||
|
||||
// Test 1: Simple in-memory engine
|
||||
console.log('1. Testing simple in-memory QueryEngine:');
|
||||
const docs = [
|
||||
{ id: 'doc1', content: 'PraisonAI is a high-performance AI agent framework designed for production use.' },
|
||||
{ id: 'doc2', content: 'TypeScript provides static typing and better tooling for JavaScript projects.' },
|
||||
{ id: 'doc3', content: 'Agents can use tools, memory, and knowledge to complete complex tasks.' },
|
||||
{ id: 'doc4', content: 'RAG (Retrieval-Augmented Generation) improves AI responses with relevant context.' },
|
||||
{ id: 'doc5', content: 'Workflows enable sequential and parallel execution of agent tasks.' }
|
||||
];
|
||||
|
||||
const engine = createSimpleQueryEngine(docs);
|
||||
|
||||
const results1 = await engine.query('agent framework', { topK: 2 });
|
||||
console.log(' Query: "agent framework"');
|
||||
console.log(` Found: ${results1.length} results`);
|
||||
results1.forEach((r, i) => {
|
||||
console.log(` [${i + 1}] ${r.content.slice(0, 50)}... (score: ${r.score.toFixed(2)})`);
|
||||
});
|
||||
console.log(' Success: ✅');
|
||||
|
||||
// Test 2: Different queries
|
||||
console.log('\n2. Testing various queries:');
|
||||
const queries = ['TypeScript', 'RAG', 'workflow', 'tools memory'];
|
||||
|
||||
for (const q of queries) {
|
||||
const results = await engine.query(q, { topK: 1 });
|
||||
console.log(` "${q}" → ${results.length > 0 ? results[0].content.slice(0, 40) + '...' : 'No results'}`);
|
||||
}
|
||||
console.log(' Success: ✅');
|
||||
|
||||
// Test 3: Query for context string
|
||||
console.log('\n3. Testing queryForContext:');
|
||||
const context = await engine.queryForContext('agent tasks', { topK: 2 });
|
||||
console.log(' Context string (preview):');
|
||||
console.log(' ' + context.split('\n')[0]);
|
||||
console.log(' Success: ✅');
|
||||
|
||||
// Test 4: Cache behavior
|
||||
console.log('\n4. Testing caching:');
|
||||
const start = Date.now();
|
||||
await engine.query('agent framework', { topK: 2 });
|
||||
const cachedTime = Date.now() - start;
|
||||
console.log(` Cached query time: ${cachedTime}ms (should be fast)`);
|
||||
|
||||
engine.clearCache();
|
||||
const start2 = Date.now();
|
||||
await engine.query('agent framework', { topK: 2 });
|
||||
const uncachedTime = Date.now() - start2;
|
||||
console.log(` Uncached query time: ${uncachedTime}ms`);
|
||||
console.log(' Success: ✅');
|
||||
|
||||
// Test 5: QueryEngine configuration
|
||||
console.log('\n5. Testing custom QueryEngine configuration:');
|
||||
const customEngine = new QueryEngine({
|
||||
keywordSearch: async (query, options) => {
|
||||
return docs
|
||||
.filter(d => d.content.toLowerCase().includes(query.toLowerCase()))
|
||||
.map(d => ({ id: d.id, content: d.content, score: 0.8 }))
|
||||
.slice(0, options?.topK ?? 5);
|
||||
},
|
||||
defaultOptions: { mode: 'keyword', topK: 3 }
|
||||
});
|
||||
|
||||
const customResults = await customEngine.query('TypeScript');
|
||||
console.log(` Custom engine results: ${customResults.length}`);
|
||||
console.log(' Success: ✅');
|
||||
|
||||
// Test 6: Empty results handling
|
||||
console.log('\n6. Testing empty results:');
|
||||
const emptyResults = await engine.query('nonexistent_query_12345');
|
||||
console.log(` Results for nonsense query: ${emptyResults.length}`);
|
||||
|
||||
const emptyContext = await engine.queryForContext('nonexistent_query_12345');
|
||||
console.log(` Context: "${emptyContext}"`);
|
||||
console.log(' Success: ✅');
|
||||
|
||||
console.log('\n=== QueryEngine Tests Complete ===');
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* MCP Client Integration Test
|
||||
*
|
||||
* Tests the MCP (Model Context Protocol) client.
|
||||
* Note: Requires an MCP server for full integration tests.
|
||||
*
|
||||
* Run: npx ts-node mcp-client.ts
|
||||
*/
|
||||
|
||||
import {
|
||||
MCPClient,
|
||||
createMCPClient,
|
||||
getMCPTools,
|
||||
type MCPClientConfig
|
||||
} from '../../../src/praisonai-ts/dist/mcp';
|
||||
|
||||
async function main() {
|
||||
console.log('=== MCPClient Integration Test ===\n');
|
||||
|
||||
// Test 1: Create MCP client
|
||||
console.log('1. Testing MCPClient creation:');
|
||||
const client = new MCPClient({
|
||||
serverUrl: 'http://localhost:3000',
|
||||
transport: 'http',
|
||||
timeout: 5000,
|
||||
verbose: true
|
||||
});
|
||||
console.log(' Client ID:', client.id);
|
||||
console.log(' Connected:', client.isConnected());
|
||||
console.log(' Success: ✅');
|
||||
|
||||
// Test 2: Factory function
|
||||
console.log('\n2. Testing createMCPClient() factory:');
|
||||
const client2 = createMCPClient({ serverUrl: 'http://localhost:8080' });
|
||||
console.log(' Client created:', typeof client2);
|
||||
console.log(' Success: ✅');
|
||||
|
||||
// Test 3: Transport configurations
|
||||
console.log('\n3. Testing transport configurations:');
|
||||
const transports: MCPClientConfig['transport'][] = ['http', 'websocket', 'sse', 'stdio'];
|
||||
|
||||
for (const transport of transports) {
|
||||
try {
|
||||
const c = new MCPClient({
|
||||
transport,
|
||||
serverUrl: transport !== 'stdio' ? 'http://localhost:3000' : undefined,
|
||||
command: transport === 'stdio' ? 'npx' : undefined,
|
||||
args: transport === 'stdio' ? ['mcp-server'] : undefined
|
||||
});
|
||||
console.log(` ${transport}: ✅ (configured)`);
|
||||
} catch (error: any) {
|
||||
console.log(` ${transport}: ⚠️ ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Test 4: Session info
|
||||
console.log('\n4. Testing session management:');
|
||||
const session = client.getSession();
|
||||
console.log(' Session ID:', session.id);
|
||||
console.log(' Connected:', session.connected);
|
||||
console.log(' Tools:', session.tools.length);
|
||||
console.log(' Resources:', session.resources.length);
|
||||
console.log(' Success: ✅');
|
||||
|
||||
// Test 5: Live connection test (optional - requires running server)
|
||||
const testServerUrl = process.env.MCP_SERVER_URL;
|
||||
if (testServerUrl) {
|
||||
console.log('\n5. Testing live MCP connection:');
|
||||
try {
|
||||
const liveClient = new MCPClient({
|
||||
serverUrl: testServerUrl,
|
||||
transport: 'http',
|
||||
timeout: 10000,
|
||||
verbose: false
|
||||
});
|
||||
|
||||
await liveClient.connect();
|
||||
console.log(' Connected: ✅');
|
||||
|
||||
const tools = await liveClient.listTools();
|
||||
console.log(` Tools discovered: ${tools.length}`);
|
||||
|
||||
for (const tool of tools.slice(0, 3)) {
|
||||
console.log(` - ${tool.name}: ${tool.description || 'No description'}`);
|
||||
}
|
||||
|
||||
// Get tools in AI SDK format
|
||||
const aiTools = await liveClient.getToolsAsAISDK();
|
||||
console.log(` AI SDK tools: ${aiTools.length}`);
|
||||
|
||||
await liveClient.disconnect();
|
||||
console.log(' Disconnected: ✅');
|
||||
} catch (error: any) {
|
||||
console.log(` ⚠️ Connection failed: ${error.message}`);
|
||||
}
|
||||
} else {
|
||||
console.log('\n5. Live connection: Skipped (MCP_SERVER_URL not set)');
|
||||
console.log(' Set MCP_SERVER_URL to test live connections');
|
||||
}
|
||||
|
||||
// Test 6: Mock tool call simulation
|
||||
console.log('\n6. Testing tool call preparation:');
|
||||
const mockTool = {
|
||||
name: 'search',
|
||||
description: 'Search for information',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string', description: 'Search query' }
|
||||
},
|
||||
required: ['query']
|
||||
}
|
||||
};
|
||||
console.log(' Mock tool:', mockTool.name);
|
||||
console.log(' Schema:', JSON.stringify(mockTool.inputSchema));
|
||||
console.log(' Success: ✅');
|
||||
|
||||
// Test 7: API Key configuration
|
||||
console.log('\n7. Testing API key configuration:');
|
||||
const authClient = new MCPClient({
|
||||
serverUrl: 'http://api.example.com',
|
||||
apiKey: 'test-api-key-12345'
|
||||
});
|
||||
console.log(' Client with API key: ✅');
|
||||
|
||||
// Test 8: getMCPTools helper
|
||||
console.log('\n8. Testing getMCPTools helper signature:');
|
||||
console.log(' getMCPTools function:', typeof getMCPTools);
|
||||
console.log(' Returns: { client, tools }');
|
||||
console.log(' Success: ✅');
|
||||
|
||||
console.log('\n=== MCPClient Tests Complete ===');
|
||||
console.log('\nNote: For full integration tests, run an MCP server and set MCP_SERVER_URL');
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* MemoryHooks Integration Test
|
||||
*
|
||||
* Tests pre/post hooks for memory operations.
|
||||
*
|
||||
* Run: npx ts-node memory-hooks.ts
|
||||
*/
|
||||
|
||||
import {
|
||||
MemoryHooks,
|
||||
createMemoryHooks,
|
||||
createLoggingHooks,
|
||||
createValidationHooks,
|
||||
Memory,
|
||||
createMemory
|
||||
} from '../../../src/praisonai-ts/dist';
|
||||
|
||||
async function main() {
|
||||
console.log('=== MemoryHooks Integration Test ===\n');
|
||||
|
||||
// Test 1: Basic hooks
|
||||
console.log('1. Testing basic MemoryHooks:');
|
||||
const logs: string[] = [];
|
||||
|
||||
const hooks = new MemoryHooks({
|
||||
beforeStore: async (key, value) => {
|
||||
logs.push(`beforeStore: ${key}`);
|
||||
return { key, value };
|
||||
},
|
||||
afterStore: async (key) => {
|
||||
logs.push(`afterStore: ${key}`);
|
||||
},
|
||||
beforeRetrieve: async (key) => {
|
||||
logs.push(`beforeRetrieve: ${key}`);
|
||||
return key;
|
||||
},
|
||||
afterRetrieve: async (key, value) => {
|
||||
logs.push(`afterRetrieve: ${key}`);
|
||||
return value;
|
||||
}
|
||||
});
|
||||
|
||||
await hooks.beforeStore('key1', { data: 'value1' });
|
||||
await hooks.afterStore('key1', { data: 'value1' });
|
||||
await hooks.beforeRetrieve('key1');
|
||||
await hooks.afterRetrieve('key1', { data: 'value1' });
|
||||
|
||||
console.log(' Triggered hooks:', logs);
|
||||
console.log(' Success: ✅');
|
||||
|
||||
// Test 2: Logging hooks
|
||||
console.log('\n2. Testing createLoggingHooks:');
|
||||
const logMessages: string[] = [];
|
||||
const loggingHooks = createLoggingHooks((msg) => logMessages.push(msg));
|
||||
|
||||
await loggingHooks.beforeStore('test', 'data');
|
||||
await loggingHooks.afterStore('test', 'data');
|
||||
await loggingHooks.beforeRetrieve('test');
|
||||
await loggingHooks.afterRetrieve('test', 'data');
|
||||
|
||||
console.log(' Log messages:', logMessages.length);
|
||||
logMessages.forEach(m => console.log(' -', m));
|
||||
console.log(' Success: ✅');
|
||||
|
||||
// Test 3: Validation hooks
|
||||
console.log('\n3. Testing createValidationHooks:');
|
||||
const validationHooks = createValidationHooks((key, value) => {
|
||||
// Only allow non-empty strings
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
return { valid: false, reason: 'Value must be non-empty string' };
|
||||
}
|
||||
return { valid: true };
|
||||
});
|
||||
|
||||
const validResult = await validationHooks.beforeStore('good', 'valid string');
|
||||
console.log(' Valid data allowed:', validResult !== null);
|
||||
|
||||
const invalidResult = await validationHooks.beforeStore('bad', '');
|
||||
console.log(' Invalid data blocked:', invalidResult === null);
|
||||
console.log(' Success: ✅');
|
||||
|
||||
// Test 4: Delete hooks
|
||||
console.log('\n4. Testing delete hooks:');
|
||||
const deleteHooks = new MemoryHooks({
|
||||
beforeDelete: async (key) => {
|
||||
console.log(` [Hook] About to delete: ${key}`);
|
||||
return true; // Allow deletion
|
||||
},
|
||||
afterDelete: async (key, success) => {
|
||||
console.log(` [Hook] Delete ${key}: ${success ? 'succeeded' : 'failed'}`);
|
||||
}
|
||||
});
|
||||
|
||||
const canDelete = await deleteHooks.beforeDelete('item-to-delete');
|
||||
console.log(' Deletion allowed:', canDelete);
|
||||
await deleteHooks.afterDelete('item-to-delete', true);
|
||||
console.log(' Success: ✅');
|
||||
|
||||
// Test 5: Search hooks
|
||||
console.log('\n5. Testing search hooks:');
|
||||
const searchHooks = new MemoryHooks({
|
||||
beforeSearch: async (query, options) => {
|
||||
console.log(` [Hook] Searching: "${query}"`);
|
||||
// Could modify query here
|
||||
return { query: query.toLowerCase(), options };
|
||||
},
|
||||
afterSearch: async (query, results) => {
|
||||
console.log(` [Hook] Found ${results.length} results for "${query}"`);
|
||||
return results;
|
||||
}
|
||||
});
|
||||
|
||||
const searchParams = await searchHooks.beforeSearch('UPPERCASE QUERY');
|
||||
console.log(' Modified query:', searchParams?.query);
|
||||
|
||||
await searchHooks.afterSearch('query', [{ id: '1' }, { id: '2' }]);
|
||||
console.log(' Success: ✅');
|
||||
|
||||
// Test 6: Dynamic hook management
|
||||
console.log('\n6. Testing dynamic hook management:');
|
||||
const dynamicHooks = new MemoryHooks({});
|
||||
|
||||
console.log(' Initial config:', Object.keys(dynamicHooks.getConfig()).length === 0 ? 'empty' : 'has hooks');
|
||||
|
||||
dynamicHooks.addHook('logging', true);
|
||||
dynamicHooks.setLogging(true);
|
||||
|
||||
console.log(' After adding logging:', dynamicHooks.getConfig().logging);
|
||||
|
||||
dynamicHooks.removeHook('logging');
|
||||
console.log(' After removing:', dynamicHooks.getConfig().logging ?? 'undefined');
|
||||
console.log(' Success: ✅');
|
||||
|
||||
console.log('\n=== MemoryHooks Tests Complete ===');
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Feature Parity Integration Test Runner
|
||||
*
|
||||
* Runs all integration tests for the new TypeScript features.
|
||||
* Tests include real API calls when OPENAI_API_KEY is available.
|
||||
*
|
||||
* Run: npx ts-node run-feature-tests.ts
|
||||
*/
|
||||
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
interface TestResult {
|
||||
name: string;
|
||||
passed: boolean;
|
||||
duration: number;
|
||||
output: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const TEST_FILES = [
|
||||
// Workflow patterns
|
||||
'workflows/loop-pattern.ts',
|
||||
'workflows/repeat-pattern.ts',
|
||||
// Agents
|
||||
'agents/audio-agent.ts',
|
||||
// Knowledge
|
||||
'knowledge/chonkie-adapter.ts',
|
||||
// MCP
|
||||
'mcp/mcp-client-test.ts',
|
||||
];
|
||||
|
||||
async function runTest(testFile: string): Promise<TestResult> {
|
||||
const startTime = Date.now();
|
||||
const fullPath = path.join(__dirname, testFile);
|
||||
const name = path.basename(testFile, '.ts');
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn('npx', ['ts-node', fullPath], {
|
||||
cwd: __dirname,
|
||||
env: { ...process.env },
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
proc.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
const duration = Date.now() - startTime;
|
||||
resolve({
|
||||
name,
|
||||
passed: code === 0,
|
||||
duration,
|
||||
output: stdout,
|
||||
error: stderr || undefined
|
||||
});
|
||||
});
|
||||
|
||||
proc.on('error', (error) => {
|
||||
const duration = Date.now() - startTime;
|
||||
resolve({
|
||||
name,
|
||||
passed: false,
|
||||
duration,
|
||||
output: '',
|
||||
error: error.message
|
||||
});
|
||||
});
|
||||
|
||||
// Timeout after 60 seconds
|
||||
setTimeout(() => {
|
||||
proc.kill();
|
||||
resolve({
|
||||
name,
|
||||
passed: false,
|
||||
duration: 60000,
|
||||
output: stdout,
|
||||
error: 'Test timed out after 60 seconds'
|
||||
});
|
||||
}, 60000);
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('╔════════════════════════════════════════════════════════════╗');
|
||||
console.log('║ PraisonAI TypeScript Feature Parity Integration Tests ║');
|
||||
console.log('╚════════════════════════════════════════════════════════════╝\n');
|
||||
|
||||
// Environment info
|
||||
console.log('Environment:');
|
||||
console.log(` OPENAI_API_KEY: ${process.env.OPENAI_API_KEY ? '✅ Set' : '❌ Not set'}`);
|
||||
console.log(` MCP_SERVER_URL: ${process.env.MCP_SERVER_URL ? '✅ Set' : '❌ Not set'}`);
|
||||
console.log(` Node version: ${process.version}`);
|
||||
console.log('');
|
||||
|
||||
// Check if test files exist
|
||||
console.log('Test Files:');
|
||||
for (const file of TEST_FILES) {
|
||||
const fullPath = path.join(__dirname, file);
|
||||
const exists = fs.existsSync(fullPath);
|
||||
console.log(` ${exists ? '✅' : '❌'} ${file}`);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// Run tests
|
||||
console.log('Running Tests...\n');
|
||||
console.log('─'.repeat(60));
|
||||
|
||||
const results: TestResult[] = [];
|
||||
for (const testFile of TEST_FILES) {
|
||||
const fullPath = path.join(__dirname, testFile);
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
results.push({
|
||||
name: path.basename(testFile, '.ts'),
|
||||
passed: false,
|
||||
duration: 0,
|
||||
output: '',
|
||||
error: 'File not found'
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`\n▶ Running: ${testFile}`);
|
||||
const result = await runTest(testFile);
|
||||
results.push(result);
|
||||
|
||||
if (result.passed) {
|
||||
console.log(` ✅ PASSED (${result.duration}ms)`);
|
||||
} else {
|
||||
console.log(` ❌ FAILED (${result.duration}ms)`);
|
||||
if (result.error) {
|
||||
console.log(` Error: ${result.error.slice(0, 200)}...`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n' + '─'.repeat(60));
|
||||
|
||||
// Summary
|
||||
const passed = results.filter(r => r.passed).length;
|
||||
const failed = results.filter(r => !r.passed).length;
|
||||
const totalDuration = results.reduce((sum, r) => sum + r.duration, 0);
|
||||
|
||||
console.log('\n╔════════════════════════════════════════════════════════════╗');
|
||||
console.log('║ TEST SUMMARY ║');
|
||||
console.log('╚════════════════════════════════════════════════════════════╝');
|
||||
console.log(` Total Tests: ${results.length}`);
|
||||
console.log(` Passed: ${passed} ✅`);
|
||||
console.log(` Failed: ${failed} ${failed > 0 ? '❌' : ''}`);
|
||||
console.log(` Duration: ${(totalDuration / 1000).toFixed(2)}s`);
|
||||
console.log('');
|
||||
|
||||
// Detailed results
|
||||
if (failed > 0) {
|
||||
console.log('Failed Tests:');
|
||||
for (const result of results.filter(r => !r.passed)) {
|
||||
console.log(` ❌ ${result.name}`);
|
||||
if (result.error) {
|
||||
console.log(` ${result.error.slice(0, 100)}`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// Exit with appropriate code
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Test runner failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* SubagentTool Integration Test
|
||||
*
|
||||
* Tests the agent-as-tool pattern with real agents.
|
||||
*
|
||||
* Run: npx ts-node subagent-tool.ts
|
||||
* Requires: OPENAI_API_KEY
|
||||
*/
|
||||
|
||||
import {
|
||||
Agent,
|
||||
SubagentTool,
|
||||
createSubagentTool,
|
||||
createSubagentTools,
|
||||
createDelegator
|
||||
} from '../../../src/praisonai-ts/dist';
|
||||
|
||||
async function main() {
|
||||
console.log('=== SubagentTool Integration Test ===\n');
|
||||
|
||||
// Test 1: Create SubagentTool
|
||||
console.log('1. Testing SubagentTool creation:');
|
||||
const mockAgent = {
|
||||
name: 'MockAgent',
|
||||
chat: async (input: string) => `Echo: ${input}`
|
||||
};
|
||||
|
||||
const tool = new SubagentTool(mockAgent, {
|
||||
name: 'echo',
|
||||
description: 'Echo the input'
|
||||
});
|
||||
|
||||
console.log(' Tool name:', tool.name);
|
||||
console.log(' Tool ID:', tool.id);
|
||||
console.log(' Schema:', JSON.stringify(tool.getSchema()));
|
||||
console.log(' Success: ✅');
|
||||
|
||||
// Test 2: Execute tool with mock agent
|
||||
console.log('\n2. Testing tool execution:');
|
||||
const mockResult = await tool.execute({ input: 'Hello World' });
|
||||
console.log(' Input: "Hello World"');
|
||||
console.log(' Output:', mockResult);
|
||||
console.log(' Success: ✅');
|
||||
|
||||
// Test 3: Factory function
|
||||
console.log('\n3. Testing createSubagentTool factory:');
|
||||
const factoryTool = createSubagentTool(mockAgent, { name: 'factory_echo' });
|
||||
console.log(' Tool name:', factoryTool.name);
|
||||
console.log(' Success: ✅');
|
||||
|
||||
// Test 4: Multiple subagent tools
|
||||
console.log('\n4. Testing createSubagentTools (batch):');
|
||||
const agents = [
|
||||
{ name: 'Agent1', chat: async (i: string) => `A1: ${i}` },
|
||||
{ name: 'Agent2', chat: async (i: string) => `A2: ${i}` }
|
||||
];
|
||||
const tools = createSubagentTools(agents);
|
||||
console.log(' Created tools:', tools.length);
|
||||
tools.forEach(t => console.log(' -', t.name));
|
||||
console.log(' Success: ✅');
|
||||
|
||||
// Test 5: AI SDK format conversion
|
||||
console.log('\n5. Testing AI SDK format conversion:');
|
||||
const aiTool = tool.toAISDKTool();
|
||||
console.log(' Type:', aiTool.type);
|
||||
console.log(' Function name:', aiTool.function.name);
|
||||
console.log(' Has execute:', typeof aiTool.execute === 'function');
|
||||
console.log(' Success: ✅');
|
||||
|
||||
// Test 6: Live test with real agent
|
||||
if (process.env.OPENAI_API_KEY) {
|
||||
console.log('\n6. Testing with real Agent (live API):');
|
||||
try {
|
||||
const researcher = new Agent({
|
||||
name: 'QuickResearcher',
|
||||
instructions: 'Answer in exactly 5 words.',
|
||||
llm: 'openai/gpt-4o-mini'
|
||||
});
|
||||
|
||||
const researchTool = createSubagentTool(researcher, {
|
||||
name: 'quick_research',
|
||||
description: 'Get a quick 5-word answer'
|
||||
});
|
||||
|
||||
const result = await researchTool.execute({ input: 'What is AI?' });
|
||||
console.log(' Question: "What is AI?"');
|
||||
console.log(' Answer:', result);
|
||||
console.log(' Success: ✅');
|
||||
} catch (error: any) {
|
||||
console.log(' Error:', error.message);
|
||||
}
|
||||
} else {
|
||||
console.log('\n6. Live test: Skipped (OPENAI_API_KEY not set)');
|
||||
}
|
||||
|
||||
// Test 7: Input/Output transforms
|
||||
console.log('\n7. Testing transforms:');
|
||||
const transformTool = createSubagentTool(mockAgent, {
|
||||
name: 'transform_echo',
|
||||
inputTransform: (input) => input.toUpperCase(),
|
||||
outputTransform: (output) => `[TRANSFORMED] ${output}`
|
||||
});
|
||||
|
||||
const transformResult = await transformTool.execute({ input: 'hello' });
|
||||
console.log(' Input: "hello"');
|
||||
console.log(' Output:', transformResult);
|
||||
console.log(' Success: ✅');
|
||||
|
||||
console.log('\n=== SubagentTool Tests Complete ===');
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Loop Pattern Integration Test
|
||||
*
|
||||
* Tests the Loop workflow pattern with real data.
|
||||
*
|
||||
* Run: npx ts-node loop-pattern.ts
|
||||
*/
|
||||
|
||||
import { Loop, loopPattern, Agent, Workflow } from '../../../src/praisonai-ts/dist';
|
||||
|
||||
async function main() {
|
||||
console.log('=== Loop Pattern Integration Test ===\n');
|
||||
|
||||
// Test 1: Basic Loop with array
|
||||
console.log('1. Testing Loop with array iteration:');
|
||||
const items = ['apple', 'banana', 'cherry'];
|
||||
|
||||
const processor = (item: string) => `Processed: ${item.toUpperCase()}`;
|
||||
const arrayLoop = new Loop(processor, { over: 'items' });
|
||||
|
||||
const result1 = await arrayLoop.run({ items });
|
||||
console.log(' Results:', result1.results);
|
||||
console.log(' Iterations:', result1.iterations);
|
||||
console.log(' Success:', result1.success ? '✅' : '❌');
|
||||
|
||||
// Test 2: Loop with convenience function
|
||||
console.log('\n2. Testing loop() convenience function:');
|
||||
const doubler = (n: number) => n * 2;
|
||||
const numberLoop = loopPattern(doubler, { over: 'numbers' });
|
||||
|
||||
const result2 = await numberLoop.run({ numbers: [1, 2, 3, 4, 5] });
|
||||
console.log(' Input: [1, 2, 3, 4, 5]');
|
||||
console.log(' Output:', result2.results);
|
||||
console.log(' Success:', result2.success ? '✅' : '❌');
|
||||
|
||||
// Test 3: Loop with Agent (requires OPENAI_API_KEY)
|
||||
if (process.env.OPENAI_API_KEY) {
|
||||
console.log('\n3. Testing Loop with Agent (live API):');
|
||||
try {
|
||||
const agent = new Agent({
|
||||
name: 'Summarizer',
|
||||
instructions: 'Summarize the given topic in one sentence.',
|
||||
llm: 'openai/gpt-4o-mini'
|
||||
});
|
||||
|
||||
const topics = ['AI', 'TypeScript'];
|
||||
const agentLoop = new Loop(agent, { over: 'topics', varName: 'topic' });
|
||||
|
||||
console.log(' Processing topics:', topics);
|
||||
const result3 = await agentLoop.run({ topics });
|
||||
|
||||
result3.results.forEach((res, i) => {
|
||||
console.log(` [${topics[i]}]: ${res.slice(0, 80)}...`);
|
||||
});
|
||||
console.log(' Success:', result3.success ? '✅' : '❌');
|
||||
} catch (error: any) {
|
||||
console.log(' ⚠️ Agent test failed:', error.message);
|
||||
}
|
||||
} else {
|
||||
console.log('\n3. Agent Loop Test: Skipped (OPENAI_API_KEY not set)');
|
||||
}
|
||||
|
||||
// Test 4: Loop with error handling
|
||||
console.log('\n4. Testing Loop with continueOnError:');
|
||||
const errorProneProcessor = (item: string) => {
|
||||
if (item === 'fail') throw new Error('Intentional failure');
|
||||
return `OK: ${item}`;
|
||||
};
|
||||
|
||||
const errorLoop = new Loop(errorProneProcessor, {
|
||||
over: 'items',
|
||||
continueOnError: true
|
||||
});
|
||||
|
||||
const result4 = await errorLoop.run({ items: ['a', 'fail', 'b'] });
|
||||
console.log(' Results:', result4.results);
|
||||
console.log(' Errors:', result4.errors.length);
|
||||
console.log(' Success:', result4.success ? '✅' : '❌ (expected - has errors)');
|
||||
|
||||
// Test 5: Loop type exports
|
||||
console.log('\n5. Verifying exports:');
|
||||
console.log(' Loop class:', typeof Loop);
|
||||
console.log(' loop function:', typeof loopPattern);
|
||||
|
||||
console.log('\n=== Loop Pattern Tests Complete ===');
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Repeat Pattern Integration Test
|
||||
*
|
||||
* Tests the Repeat workflow pattern for evaluator-optimizer loops.
|
||||
*
|
||||
* Run: npx ts-node repeat-pattern.ts
|
||||
*/
|
||||
|
||||
import { Repeat, repeatPattern, Agent } from '../../../src/praisonai-ts/dist';
|
||||
|
||||
async function main() {
|
||||
console.log('=== Repeat Pattern Integration Test ===\n');
|
||||
|
||||
// Test 1: Basic repeat until condition
|
||||
console.log('1. Testing Repeat with condition:');
|
||||
let counter = 0;
|
||||
const incrementer = () => {
|
||||
counter++;
|
||||
return `Iteration ${counter}`;
|
||||
};
|
||||
|
||||
const repeat1 = new Repeat(incrementer, {
|
||||
until: (ctx) => ctx.iteration >= 3,
|
||||
maxIterations: 10
|
||||
});
|
||||
|
||||
const result1 = await repeat1.run('start');
|
||||
console.log(' Results:', result1.iterations);
|
||||
console.log(' Final:', result1.result);
|
||||
console.log(' Condition met:', result1.conditionMet ? '✅' : '❌');
|
||||
console.log(' Max reached:', result1.maxReached ? '⚠️' : 'No');
|
||||
|
||||
// Test 2: Repeat with content condition
|
||||
console.log('\n2. Testing Repeat with content-based condition:');
|
||||
let quality = 0;
|
||||
const improver = (input: string, prev: string | null) => {
|
||||
quality += 25;
|
||||
return `Quality: ${quality}% - ${prev || input}`;
|
||||
};
|
||||
|
||||
const repeat2 = new Repeat(improver, {
|
||||
until: (ctx) => ctx.lastResult?.includes('100%') ?? false,
|
||||
maxIterations: 5
|
||||
});
|
||||
|
||||
quality = 0; // Reset
|
||||
const result2 = await repeat2.run('Initial draft');
|
||||
console.log(' Iterations:', result2.count);
|
||||
console.log(' Final:', result2.result);
|
||||
console.log(' Condition met:', result2.conditionMet ? '✅' : '❌');
|
||||
|
||||
// Test 3: Repeat reaching max iterations
|
||||
console.log('\n3. Testing Repeat hitting max iterations:');
|
||||
const neverDone = () => 'still working...';
|
||||
|
||||
const repeat3 = new Repeat(neverDone, {
|
||||
until: () => false, // Never satisfied
|
||||
maxIterations: 3
|
||||
});
|
||||
|
||||
const result3 = await repeat3.run('start');
|
||||
console.log(' Iterations:', result3.count);
|
||||
console.log(' Max reached:', result3.maxReached ? '✅ (expected)' : '❌');
|
||||
|
||||
// Test 4: Repeat with Agent (requires OPENAI_API_KEY)
|
||||
if (process.env.OPENAI_API_KEY) {
|
||||
console.log('\n4. Testing Repeat with Agent (live API):');
|
||||
try {
|
||||
const agent = new Agent({
|
||||
name: 'Optimizer',
|
||||
instructions: 'Improve the given text. Add more detail and clarity. Say "DONE" when satisfied.',
|
||||
llm: 'openai/gpt-4o-mini'
|
||||
});
|
||||
|
||||
const repeat4 = new Repeat(agent, {
|
||||
until: (ctx) => ctx.lastResult?.includes('DONE') ?? false,
|
||||
maxIterations: 3
|
||||
});
|
||||
|
||||
const result4 = await repeat4.run('Make this better: Hello world');
|
||||
console.log(' Iterations:', result4.count);
|
||||
console.log(' Final (truncated):', result4.result.slice(0, 100) + '...');
|
||||
console.log(' Condition met:', result4.conditionMet ? '✅' : 'Max reached');
|
||||
} catch (error: any) {
|
||||
console.log(' ⚠️ Agent test failed:', error.message);
|
||||
}
|
||||
} else {
|
||||
console.log('\n4. Agent Repeat Test: Skipped (OPENAI_API_KEY not set)');
|
||||
}
|
||||
|
||||
// Test 5: Convenience function
|
||||
console.log('\n5. Testing repeatPattern() convenience function:');
|
||||
let seq = 0;
|
||||
const sequencer = () => `seq-${++seq}`;
|
||||
|
||||
const repeat5 = repeatPattern(sequencer, {
|
||||
until: (ctx) => ctx.iteration >= 2,
|
||||
maxIterations: 5
|
||||
});
|
||||
|
||||
seq = 0; // Reset
|
||||
const result5 = await repeat5.run('');
|
||||
console.log(' Results:', result5.iterations);
|
||||
console.log(' Success:', result5.conditionMet ? '✅' : '❌');
|
||||
|
||||
// Test 6: Verify exports
|
||||
console.log('\n6. Verifying exports:');
|
||||
console.log(' Repeat class:', typeof Repeat);
|
||||
console.log(' repeatPattern function:', typeof repeatPattern);
|
||||
|
||||
console.log('\n=== Repeat Pattern Tests Complete ===');
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -0,0 +1,10 @@
|
||||
from praisonaiagents import AudioAgent
|
||||
|
||||
# Text to Speech
|
||||
tts = AudioAgent(llm="openai/tts-1")
|
||||
tts.say("Hello!", output="hello.mp3")
|
||||
|
||||
# Speech to Text
|
||||
stt = AudioAgent(llm="openai/whisper-1")
|
||||
text = stt.listen("hello.mp3")
|
||||
print(text)
|
||||
@@ -0,0 +1,7 @@
|
||||
from praisonaiagents import AudioAgent
|
||||
|
||||
agent = AudioAgent(llm="groq/whisper-large-v3") # 10x faster
|
||||
text = agent.transcribe("audio.mp3", language="en")
|
||||
print(text)
|
||||
|
||||
# Models: openai/whisper-1, groq/whisper-large-v3, deepgram/nova-2
|
||||
@@ -0,0 +1,8 @@
|
||||
# Speech-to-Text with Groq (fastest)
|
||||
# Requires: export GROQ_API_KEY=your-key
|
||||
|
||||
from praisonaiagents import AudioAgent
|
||||
|
||||
agent = AudioAgent(llm="groq/whisper-large-v3")
|
||||
text = agent.listen("audio.mp3") # Replace with your audio file
|
||||
print(text)
|
||||
@@ -0,0 +1,5 @@
|
||||
from praisonaiagents import AudioAgent
|
||||
|
||||
agent = AudioAgent(llm="deepgram/nova-2")
|
||||
text = agent.listen("audio.mp3")
|
||||
print(text)
|
||||
@@ -0,0 +1,5 @@
|
||||
from praisonaiagents import AudioAgent
|
||||
|
||||
agent = AudioAgent(llm="groq/whisper-large-v3")
|
||||
text = agent.listen("audio.mp3")
|
||||
print(text)
|
||||
@@ -0,0 +1,5 @@
|
||||
from praisonaiagents import AudioAgent
|
||||
|
||||
agent = AudioAgent(llm="openai/whisper-1")
|
||||
text = agent.listen("audio.mp3")
|
||||
print(text)
|
||||
@@ -0,0 +1,7 @@
|
||||
from praisonaiagents import AudioAgent
|
||||
|
||||
agent = AudioAgent(llm="openai/tts-1-hd")
|
||||
agent.speech("Hello!", voice="nova", speed=1.2, output="hello.mp3")
|
||||
|
||||
# Voices: alloy, echo, fable, onyx, nova, shimmer
|
||||
# Models: openai/tts-1, openai/tts-1-hd, openai/gpt-4o-mini-tts
|
||||
@@ -0,0 +1,8 @@
|
||||
# Text-to-Speech with OpenAI
|
||||
# Requires: export OPENAI_API_KEY=your-key
|
||||
|
||||
from praisonaiagents import AudioAgent
|
||||
|
||||
agent = AudioAgent(llm="openai/tts-1")
|
||||
agent.say("Hello world!", output="hello.mp3")
|
||||
print("Saved to hello.mp3")
|
||||
@@ -0,0 +1,4 @@
|
||||
from praisonaiagents import AudioAgent
|
||||
|
||||
agent = AudioAgent(llm="elevenlabs/eleven_multilingual_v2")
|
||||
agent.say("Hello world!", output="hello.mp3")
|
||||
@@ -0,0 +1,4 @@
|
||||
from praisonaiagents import AudioAgent
|
||||
|
||||
agent = AudioAgent(llm="gemini/gemini-2.5-flash-preview-tts")
|
||||
agent.say("Hello world!", output="hello.mp3", voice="leda")
|
||||
@@ -0,0 +1,4 @@
|
||||
from praisonaiagents import AudioAgent
|
||||
|
||||
agent = AudioAgent(llm="openai/tts-1")
|
||||
agent.say("Hello world!", output="hello.mp3")
|
||||
@@ -0,0 +1,17 @@
|
||||
from praisonaiagents import ImageAgent
|
||||
|
||||
agent = ImageAgent(llm="openai/dall-e-3", style="vivid")
|
||||
|
||||
# Generate with options
|
||||
result = agent.generate("A futuristic city", size="1792x1024", quality="hd")
|
||||
print(result.data[0].url)
|
||||
|
||||
# Edit image (dall-e-2 only)
|
||||
agent2 = ImageAgent(llm="openai/dall-e-2")
|
||||
edited = agent2.edit("photo.png", "Add a rainbow")
|
||||
print(edited.data[0].url)
|
||||
|
||||
# Generate variations
|
||||
variations = agent2.variation("photo.png", n=3)
|
||||
for img in variations.data:
|
||||
print(img.url)
|
||||
@@ -0,0 +1,8 @@
|
||||
# Image Generation with DALL-E
|
||||
# Requires: export OPENAI_API_KEY=your-key
|
||||
|
||||
from praisonaiagents import ImageAgent
|
||||
|
||||
agent = ImageAgent(llm="openai/dall-e-3")
|
||||
result = agent.generate("A sunset over mountains")
|
||||
print(result.data[0].url)
|
||||
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
MCP Auth Storage Example
|
||||
|
||||
This example demonstrates how to use the MCPAuthStorage class
|
||||
to manage OAuth tokens for remote MCP servers.
|
||||
"""
|
||||
|
||||
from praisonaiagents.mcp import (
|
||||
MCPAuthStorage,
|
||||
generate_state,
|
||||
generate_code_verifier,
|
||||
generate_code_challenge,
|
||||
get_redirect_url
|
||||
)
|
||||
import time
|
||||
|
||||
# Initialize auth storage
|
||||
storage = MCPAuthStorage()
|
||||
print(f"Auth storage path: {storage.filepath}")
|
||||
|
||||
# Generate PKCE parameters for OAuth flow
|
||||
state = generate_state()
|
||||
verifier = generate_code_verifier()
|
||||
challenge = generate_code_challenge(verifier)
|
||||
redirect_url = get_redirect_url()
|
||||
|
||||
print("\n=== PKCE Parameters ===")
|
||||
print(f"State: {state[:30]}...")
|
||||
print(f"Verifier: {verifier[:30]}...")
|
||||
print(f"Challenge: {challenge[:30]}...")
|
||||
print(f"Redirect URL: {redirect_url}")
|
||||
|
||||
# Store tokens (simulating OAuth callback)
|
||||
print("\n=== Storing Tokens ===")
|
||||
storage.set_tokens("example-server", {
|
||||
"access_token": "example_access_token_12345",
|
||||
"refresh_token": "example_refresh_token_67890",
|
||||
"expires_at": time.time() + 3600, # 1 hour from now
|
||||
"scope": "read write"
|
||||
}, server_url="https://mcp.example.com/mcp")
|
||||
print("Tokens stored successfully")
|
||||
|
||||
# Retrieve tokens
|
||||
entry = storage.get("example-server")
|
||||
print("\n=== Retrieved Entry ===")
|
||||
print(f"Server URL: {entry['server_url']}")
|
||||
print(f"Access Token: {entry['tokens']['access_token'][:20]}...")
|
||||
print(f"Expires At: {entry['tokens']['expires_at']}")
|
||||
|
||||
# Check token expiration
|
||||
is_expired = storage.is_token_expired("example-server")
|
||||
print("\n=== Token Status ===")
|
||||
print(f"Is Expired: {is_expired}")
|
||||
|
||||
# URL validation
|
||||
print("\n=== URL Validation ===")
|
||||
valid = storage.get_for_url("example-server", "https://mcp.example.com/mcp")
|
||||
print(f"Same URL: {valid is not None}")
|
||||
|
||||
invalid = storage.get_for_url("example-server", "https://different.url/mcp")
|
||||
print(f"Different URL: {invalid is None}")
|
||||
|
||||
# List all entries
|
||||
print("\n=== All Entries ===")
|
||||
all_entries = storage.all()
|
||||
for name, entry in all_entries.items():
|
||||
print(f" {name}: {entry.get('server_url', 'N/A')}")
|
||||
|
||||
# Cleanup
|
||||
storage.remove("example-server")
|
||||
print("\n=== Cleanup ===")
|
||||
print("Removed example-server entry")
|
||||
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
Remote MCP Server with OAuth Authentication Example
|
||||
|
||||
This example shows how to connect to a remote MCP server
|
||||
that requires OAuth 2.1 authentication.
|
||||
|
||||
Prerequisites:
|
||||
1. Configure the remote server in ~/.praison/config.yaml
|
||||
2. Run: praisonai mcp auth <server-name>
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, MCP
|
||||
from praisonaiagents.mcp import MCPAuthStorage
|
||||
|
||||
# Check if authenticated
|
||||
storage = MCPAuthStorage()
|
||||
entry = storage.get("my-remote-server")
|
||||
|
||||
if not entry or not entry.get("tokens"):
|
||||
print("Not authenticated. Run: praisonai mcp auth my-remote-server")
|
||||
exit(1)
|
||||
|
||||
# Create agent with remote MCP server
|
||||
agent = Agent(
|
||||
name="Remote Assistant",
|
||||
instructions="You help with tasks using the remote MCP server.",
|
||||
llm="gpt-4o-mini",
|
||||
tools=MCP("https://mcp.example.com/mcp")
|
||||
)
|
||||
|
||||
# Start the agent
|
||||
agent.start("What tools are available?")
|
||||
@@ -0,0 +1,12 @@
|
||||
from praisonaiagents import OCRAgent
|
||||
|
||||
agent = OCRAgent()
|
||||
|
||||
# Extract with page selection (pages is 0-indexed)
|
||||
result = agent.extract("https://arxiv.org/pdf/2201.04234", pages=[0, 1])
|
||||
for page in result.pages:
|
||||
print(f"Page {page.index}: {page.markdown[:200]}")
|
||||
|
||||
# Quick read - just get the text
|
||||
text = agent.read("https://arxiv.org/pdf/2201.04234")
|
||||
print(f"Total length: {len(text)} chars")
|
||||
@@ -0,0 +1,9 @@
|
||||
# OCR with Mistral
|
||||
# Requires: export MISTRAL_API_KEY=your-key
|
||||
# Note: Source must be a URL (https://) or base64
|
||||
|
||||
from praisonaiagents import OCRAgent
|
||||
|
||||
agent = OCRAgent()
|
||||
text = agent.read("https://arxiv.org/pdf/2201.04234")
|
||||
print(text[:500]) # First 500 chars
|
||||
@@ -0,0 +1,5 @@
|
||||
from praisonaiagents import OCRAgent
|
||||
|
||||
agent = OCRAgent(llm="mistral/mistral-ocr-latest")
|
||||
text = agent.read("https://arxiv.org/pdf/2201.04234")
|
||||
print(text[:500]) # First 500 chars
|
||||
@@ -0,0 +1,17 @@
|
||||
from praisonaiagents import VideoAgent
|
||||
|
||||
agent = VideoAgent(llm="gemini/veo-3.1-generate-preview")
|
||||
|
||||
# Generate video
|
||||
video = agent.generate("A sunset timelapse", seconds="8", size="1920x1080")
|
||||
print(f"Started: {video.id}")
|
||||
|
||||
# Wait and download
|
||||
completed = agent.wait_for_completion(video.id)
|
||||
if completed.status == "completed":
|
||||
agent.download(video.id, "sunset.mp4")
|
||||
|
||||
# List all videos
|
||||
videos = agent.list()
|
||||
|
||||
# Models: openai/sora-2, gemini/veo-3.0-generate-preview, azure/sora-2
|
||||
@@ -0,0 +1,5 @@
|
||||
from praisonaiagents import VideoAgent
|
||||
|
||||
agent = VideoAgent(llm="azure/sora-2")
|
||||
video = agent.generate("Mountain landscape")
|
||||
print(video.id)
|
||||
@@ -0,0 +1,9 @@
|
||||
# Video Generation with OpenAI Sora
|
||||
# Requires: export OPENAI_API_KEY=your-key
|
||||
# Note: Sora requires organization verification
|
||||
|
||||
from praisonaiagents import VideoAgent
|
||||
|
||||
agent = VideoAgent(llm="openai/sora-2")
|
||||
video = agent.generate("A cat playing with yarn")
|
||||
print(f"Video ID: {video.id}")
|
||||
@@ -0,0 +1,5 @@
|
||||
from praisonaiagents import VideoAgent
|
||||
|
||||
agent = VideoAgent(llm="gemini/veo-3.1-generate-preview")
|
||||
video = agent.generate("Ocean waves at sunset")
|
||||
print(video.id)
|
||||
@@ -0,0 +1,5 @@
|
||||
from praisonaiagents import VideoAgent
|
||||
|
||||
agent = VideoAgent(llm="openai/sora-2")
|
||||
video = agent.generate("A cat playing with yarn")
|
||||
print(video.id)
|
||||
@@ -0,0 +1,5 @@
|
||||
from praisonaiagents import VideoAgent
|
||||
|
||||
agent = VideoAgent(llm="runwayml/gen4_turbo")
|
||||
video = agent.generate("Animate this forest", size="1280x720", seconds="10")
|
||||
print(video.id)
|
||||
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
Recipe Output Modes Example
|
||||
|
||||
Demonstrates how to use different output modes when running recipes.
|
||||
Output modes control the verbosity and format of execution feedback.
|
||||
|
||||
Available modes:
|
||||
- silent: No output (default, best performance)
|
||||
- status: Shows tool calls inline
|
||||
- trace: Timestamped execution trace
|
||||
- verbose: Full interactive output with panels
|
||||
- debug: Trace + metrics (tokens, cost, model)
|
||||
- json: Machine-readable JSONL events
|
||||
|
||||
CLI Usage:
|
||||
praisonai recipe run my-recipe --output status
|
||||
praisonai recipe run my-recipe --output trace
|
||||
praisonai recipe run my-recipe -v # alias for --output verbose
|
||||
"""
|
||||
|
||||
from praisonai.recipe import run
|
||||
|
||||
|
||||
def example_silent_mode():
|
||||
"""Silent mode - no output, best performance."""
|
||||
print("=== Silent Mode (default) ===")
|
||||
result = run(
|
||||
"my-recipe",
|
||||
input={"query": "Hello"},
|
||||
options={"output": "silent"}
|
||||
)
|
||||
print(f"Result: {result}")
|
||||
|
||||
|
||||
def example_status_mode():
|
||||
"""Status mode - shows tool calls inline."""
|
||||
print("\n=== Status Mode ===")
|
||||
print("Shows: ▸ tool → result ✓")
|
||||
result = run(
|
||||
"my-recipe",
|
||||
input={"query": "Hello"},
|
||||
options={"output": "status"}
|
||||
)
|
||||
print(f"Result: {result}")
|
||||
|
||||
|
||||
def example_trace_mode():
|
||||
"""Trace mode - timestamped execution trace."""
|
||||
print("\n=== Trace Mode ===")
|
||||
print("Shows: [HH:MM:SS] ▸ tool → result [0.2s] ✓")
|
||||
result = run(
|
||||
"my-recipe",
|
||||
input={"query": "Hello"},
|
||||
options={"output": "trace"}
|
||||
)
|
||||
print(f"Result: {result}")
|
||||
|
||||
|
||||
def example_verbose_mode():
|
||||
"""Verbose mode - full interactive output with panels."""
|
||||
print("\n=== Verbose Mode ===")
|
||||
result = run(
|
||||
"my-recipe",
|
||||
input={"query": "Hello"},
|
||||
options={"output": "verbose"}
|
||||
)
|
||||
print(f"Result: {result}")
|
||||
|
||||
|
||||
def example_backward_compat():
|
||||
"""Backward compatibility - verbose flag maps to output='verbose'."""
|
||||
print("\n=== Backward Compatibility ===")
|
||||
print("Using verbose=True (deprecated, use output='verbose' instead)")
|
||||
result = run(
|
||||
"my-recipe",
|
||||
input={"query": "Hello"},
|
||||
options={"verbose": True} # Maps to output="verbose"
|
||||
)
|
||||
print(f"Result: {result}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(__doc__)
|
||||
print("Note: These examples require a recipe named 'my-recipe' to be available.")
|
||||
print("Create one with: praisonai recipe init my-recipe")
|
||||
print()
|
||||
|
||||
# Uncomment to run examples:
|
||||
# example_silent_mode()
|
||||
# example_status_mode()
|
||||
# example_trace_mode()
|
||||
# example_verbose_mode()
|
||||
# example_backward_compat()
|
||||
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
Workflow Output Modes Example
|
||||
|
||||
Demonstrates how to use different output modes with Workflow.
|
||||
Workflows use the same output presets as Agent for consistency (DRY approach).
|
||||
|
||||
Available presets:
|
||||
- silent: No output (default, fastest)
|
||||
- status: Tool calls + final output inline
|
||||
- trace: Timestamped execution trace
|
||||
- verbose: Full Rich panels
|
||||
- debug: Trace + metrics
|
||||
- json: JSONL output for piping
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, Workflow
|
||||
|
||||
|
||||
def main():
|
||||
# Create a simple agent
|
||||
researcher = Agent(
|
||||
name="Researcher",
|
||||
role="Research Analyst",
|
||||
goal="Research and provide information",
|
||||
instructions="You are a research analyst. Provide concise, factual information."
|
||||
)
|
||||
|
||||
# Example 1: Status output (recommended for CLI)
|
||||
print("=" * 50)
|
||||
print("Example 1: Status Output (output='status')")
|
||||
print("=" * 50)
|
||||
|
||||
workflow_status = Workflow(
|
||||
name="Status Example",
|
||||
steps=[researcher],
|
||||
output="status" # Shows: ▸ tool → result ✓
|
||||
)
|
||||
# result = workflow_status.start("What is Python?")
|
||||
print("Workflow created with output='status'")
|
||||
print(f" actions_trace: {getattr(workflow_status._output_config, 'actions_trace', False)}")
|
||||
print()
|
||||
|
||||
# Example 2: Trace output (with timestamps)
|
||||
print("=" * 50)
|
||||
print("Example 2: Trace Output (output='trace')")
|
||||
print("=" * 50)
|
||||
|
||||
workflow_trace = Workflow(
|
||||
name="Trace Example",
|
||||
steps=[researcher],
|
||||
output="trace" # Shows: [HH:MM:SS] ▸ tool → result [0.2s] ✓
|
||||
)
|
||||
print("Workflow created with output='trace'")
|
||||
print(f" status_trace: {getattr(workflow_trace._output_config, 'status_trace', False)}")
|
||||
print()
|
||||
|
||||
# Example 3: Verbose output (Rich panels)
|
||||
print("=" * 50)
|
||||
print("Example 3: Verbose Output (output='verbose')")
|
||||
print("=" * 50)
|
||||
|
||||
workflow_verbose = Workflow(
|
||||
name="Verbose Example",
|
||||
steps=[researcher],
|
||||
output="verbose" # Full interactive display
|
||||
)
|
||||
print("Workflow created with output='verbose'")
|
||||
print(f" verbose: {getattr(workflow_verbose._output_config, 'verbose', False)}")
|
||||
print()
|
||||
|
||||
# Example 4: Silent output (default, fastest)
|
||||
print("=" * 50)
|
||||
print("Example 4: Silent Output (output='silent' or default)")
|
||||
print("=" * 50)
|
||||
|
||||
workflow_silent = Workflow(
|
||||
name="Silent Example",
|
||||
steps=[researcher],
|
||||
output="silent" # No output - zero overhead
|
||||
)
|
||||
print("Workflow created with output='silent'")
|
||||
print(f" verbose: {getattr(workflow_silent._output_config, 'verbose', False)}")
|
||||
print(f" actions_trace: {getattr(workflow_silent._output_config, 'actions_trace', False)}")
|
||||
print()
|
||||
|
||||
# Example 5: JSON output (for piping)
|
||||
print("=" * 50)
|
||||
print("Example 5: JSON Output (output='json')")
|
||||
print("=" * 50)
|
||||
|
||||
workflow_json = Workflow(
|
||||
name="JSON Example",
|
||||
steps=[researcher],
|
||||
output="json" # JSONL output for scripting
|
||||
)
|
||||
print("Workflow created with output='json'")
|
||||
print(f" json_output: {getattr(workflow_json._output_config, 'json_output', False)}")
|
||||
print()
|
||||
|
||||
print("=" * 50)
|
||||
print("All examples completed successfully!")
|
||||
print("=" * 50)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -3,6 +3,9 @@
|
||||
Agent-level guardrails example.
|
||||
This example demonstrates how to use guardrails at the Agent level
|
||||
which will apply to all tasks executed by that agent.
|
||||
|
||||
Note: Agent guardrails only support function-based validation or preset strings
|
||||
(permissive, safety, strict). For LLM-based guardrails, use Task guardrails.
|
||||
"""
|
||||
from typing import Tuple, Any
|
||||
from praisonaiagents import Agent, TaskOutput
|
||||
@@ -38,10 +41,10 @@ def validate_professional_tone(task_output: TaskOutput) -> Tuple[bool, Any]:
|
||||
|
||||
|
||||
def main():
|
||||
"""Demonstrate Agent-level guardrails with function-based and LLM-based validation."""
|
||||
"""Demonstrate Agent-level guardrails with function-based validation."""
|
||||
print("=== Agent Guardrail Examples ===\n")
|
||||
|
||||
# Example 1: Function-based guardrail
|
||||
# Example 1: Function-based guardrail for content length
|
||||
print("1. Function-based guardrail (content length validation):")
|
||||
agent1 = Agent(
|
||||
name="ContentWriter",
|
||||
@@ -54,12 +57,12 @@ def main():
|
||||
except Exception as e:
|
||||
print(f"Error: {e}\n")
|
||||
|
||||
# Example 2: LLM-based guardrail (string description)
|
||||
print("2. LLM-based guardrail (professional tone validation):")
|
||||
# Example 2: Function-based guardrail for professional tone
|
||||
print("2. Function-based guardrail (professional tone validation):")
|
||||
agent2 = Agent(
|
||||
name="BusinessWriter",
|
||||
instructions="You are a business communication expert",
|
||||
guardrails="Ensure the content is professional, formal, and suitable for business communication. No casual language or slang."
|
||||
instructions="You are a business communication expert. Write formal, professional content.",
|
||||
guardrails=validate_professional_tone
|
||||
)
|
||||
try:
|
||||
result2 = agent2.start("Write a welcome message for new employees")
|
||||
@@ -67,20 +70,22 @@ def main():
|
||||
except Exception as e:
|
||||
print(f"Error: {e}\n")
|
||||
|
||||
# Example 3: Multiple agents with different guardrails
|
||||
print("3. Professional tone function-based guardrail:")
|
||||
# Example 3: Using preset guardrails (built-in options)
|
||||
print("3. Using preset guardrail ('safety'):")
|
||||
agent3 = Agent(
|
||||
name="ProfessionalWriter",
|
||||
instructions="Write professional business content",
|
||||
guardrails=validate_professional_tone
|
||||
name="SafeWriter",
|
||||
instructions="Write helpful content",
|
||||
guardrails="safety" # Valid presets: permissive, safety, strict
|
||||
)
|
||||
try:
|
||||
result3 = agent3.start("Write a casual greeting message")
|
||||
result3 = agent3.start("Write a friendly greeting message")
|
||||
print(f"Result: {result3}\n")
|
||||
except Exception as e:
|
||||
print(f"Error: {e}\n")
|
||||
|
||||
print("=== Agent Guardrails Demonstration Complete ===")
|
||||
print("\nNote: For LLM-based guardrails with custom descriptions,")
|
||||
print("use Task-level guardrails instead of Agent-level guardrails.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -26,7 +26,6 @@ agent = DeepResearchAgent(
|
||||
- Clear structure with executive summary
|
||||
""",
|
||||
model="deep-research-pro", # Auto-detected as Gemini
|
||||
output="verbose",
|
||||
)
|
||||
|
||||
print(f"Agent: {agent}")
|
||||
|
||||
@@ -25,7 +25,6 @@ agent = DeepResearchAgent(
|
||||
- Clear, structured responses
|
||||
""",
|
||||
model="o4-mini-deep-research", # or "o3-deep-research" for higher quality
|
||||
output="verbose"
|
||||
)
|
||||
|
||||
print(f"Agent: {agent}")
|
||||
|
||||
@@ -94,7 +94,7 @@ def main():
|
||||
research_system = Agents(
|
||||
agents=[research_agent],
|
||||
tasks=research_tasks,
|
||||
memory=mongodb_memory_config,
|
||||
memory=True, # Use default memory instead of mongodb config (requires proper MemoryConfig)
|
||||
output="verbose"
|
||||
)
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ researcher = Agent(name="Researcher", role="Research Analyst", output="verbose")
|
||||
writer = Agent(name="Writer", role="Content Writer", output="verbose")
|
||||
|
||||
# Step 2: Create a plan using PlanningAgent
|
||||
planner = PlanningAgent(llm="gpt-4o-mini", output="status")
|
||||
planner = PlanningAgent(llm="gpt-4o-mini")
|
||||
plan = planner.create_plan_sync(
|
||||
request="Write a short article about the top 3 benefits of meditation",
|
||||
agents=[researcher, writer],
|
||||
|
||||
@@ -56,6 +56,15 @@ from . import memory
|
||||
from . import workflows
|
||||
# Note: knowledge and mcp are lazy-loaded via __getattr__ due to heavy deps
|
||||
|
||||
# Embedding API - explicit import shadows subpackage, enabling:
|
||||
# from praisonaiagents import embedding, embeddings, EmbeddingResult, get_dimensions
|
||||
# Note: litellm is still lazy-loaded INSIDE the functions (no performance impact)
|
||||
from .embedding.embed import embedding, aembedding
|
||||
from .embedding.result import EmbeddingResult
|
||||
from .embedding.dimensions import get_dimensions
|
||||
embeddings = embedding # Plural alias (OpenAI style: client.embeddings.create)
|
||||
aembeddings = aembedding # Plural alias for async
|
||||
|
||||
# Workflows - lightweight module
|
||||
from .workflows import (
|
||||
Workflow, WorkflowStep, WorkflowContext, StepResult,
|
||||
@@ -143,6 +152,30 @@ def __getattr__(name):
|
||||
from .agent.image_agent import ImageAgent
|
||||
_lazy_cache[name] = ImageAgent
|
||||
return ImageAgent
|
||||
elif name == "VideoAgent":
|
||||
from .agent.video_agent import VideoAgent
|
||||
_lazy_cache[name] = VideoAgent
|
||||
return VideoAgent
|
||||
elif name == "VideoConfig":
|
||||
from .agent.video_agent import VideoConfig
|
||||
_lazy_cache[name] = VideoConfig
|
||||
return VideoConfig
|
||||
elif name == "AudioAgent":
|
||||
from .agent.audio_agent import AudioAgent
|
||||
_lazy_cache[name] = AudioAgent
|
||||
return AudioAgent
|
||||
elif name == "AudioConfig":
|
||||
from .agent.audio_agent import AudioConfig
|
||||
_lazy_cache[name] = AudioConfig
|
||||
return AudioConfig
|
||||
elif name == "OCRAgent":
|
||||
from .agent.ocr_agent import OCRAgent
|
||||
_lazy_cache[name] = OCRAgent
|
||||
return OCRAgent
|
||||
elif name == "OCRConfig":
|
||||
from .agent.ocr_agent import OCRConfig
|
||||
_lazy_cache[name] = OCRConfig
|
||||
return OCRConfig
|
||||
elif name == "ContextAgent":
|
||||
from .agent.context_agent import ContextAgent
|
||||
_lazy_cache[name] = ContextAgent
|
||||
@@ -368,6 +401,33 @@ def __getattr__(name):
|
||||
_lazy_cache[name] = Memory
|
||||
return Memory
|
||||
|
||||
# Embedding module (unified embedding API)
|
||||
# Note: Must import from .embed submodule to get functions, not the module
|
||||
elif name == "embedding":
|
||||
from praisonaiagents.embedding.embed import embedding
|
||||
_lazy_cache[name] = embedding
|
||||
return embedding
|
||||
elif name == "aembedding":
|
||||
from praisonaiagents.embedding.embed import aembedding
|
||||
_lazy_cache[name] = aembedding
|
||||
return aembedding
|
||||
elif name == "embed":
|
||||
from praisonaiagents.embedding.embed import embed
|
||||
_lazy_cache[name] = embed
|
||||
return embed
|
||||
elif name == "aembed":
|
||||
from praisonaiagents.embedding.embed import aembed
|
||||
_lazy_cache[name] = aembed
|
||||
return aembed
|
||||
elif name == "EmbeddingResult":
|
||||
from praisonaiagents.embedding.result import EmbeddingResult
|
||||
_lazy_cache[name] = EmbeddingResult
|
||||
return EmbeddingResult
|
||||
elif name == "get_dimensions":
|
||||
from praisonaiagents.embedding.dimensions import get_dimensions
|
||||
_lazy_cache[name] = get_dimensions
|
||||
return get_dimensions
|
||||
|
||||
# Planning mode support (lazy)
|
||||
elif name in ("Plan", "PlanStep", "TodoList", "TodoItem", "PlanStorage",
|
||||
"PlanningAgent", "ApprovalCallback", "READ_ONLY_TOOLS",
|
||||
@@ -474,7 +534,15 @@ def __getattr__(name):
|
||||
_lazy_cache[name] = result
|
||||
return result
|
||||
|
||||
# Context management config (already exists)
|
||||
# Context management config
|
||||
elif name == "ContextConfig":
|
||||
from .context.models import ContextConfig
|
||||
_lazy_cache[name] = ContextConfig
|
||||
return ContextConfig
|
||||
elif name == "OptimizerStrategy":
|
||||
from .context.models import OptimizerStrategy
|
||||
_lazy_cache[name] = OptimizerStrategy
|
||||
return OptimizerStrategy
|
||||
elif name == "ManagerConfig":
|
||||
from .context.manager import ManagerConfig
|
||||
_lazy_cache[name] = ManagerConfig
|
||||
@@ -637,6 +705,15 @@ __all__ = [
|
||||
'tool',
|
||||
'Tools',
|
||||
|
||||
# Embedding API - simplified imports
|
||||
# Usage: from praisonaiagents import embedding, EmbeddingResult
|
||||
'embedding',
|
||||
'embeddings', # Plural alias (OpenAI style)
|
||||
'aembedding',
|
||||
'aembeddings', # Plural alias for async
|
||||
'EmbeddingResult',
|
||||
'get_dimensions',
|
||||
|
||||
# Sub-packages for organized imports
|
||||
# Usage: import praisonaiagents as pa; pa.config.MemoryConfig
|
||||
'config',
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"""Agent module for AI agents"""
|
||||
from .agent import Agent
|
||||
from .image_agent import ImageAgent
|
||||
from .video_agent import VideoAgent, VideoConfig
|
||||
from .audio_agent import AudioAgent, AudioConfig
|
||||
from .ocr_agent import OCRAgent, OCRConfig
|
||||
from .context_agent import ContextAgent, create_context_agent
|
||||
from .handoff import (
|
||||
Handoff, handoff, handoff_filters,
|
||||
@@ -36,11 +39,18 @@ from .protocols import (
|
||||
ToolAwareAgentProtocol,
|
||||
MemoryAwareAgentProtocol,
|
||||
FullAgentProtocol,
|
||||
ContextEngineerProtocol,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'Agent',
|
||||
'ImageAgent',
|
||||
'VideoAgent',
|
||||
'VideoConfig',
|
||||
'AudioAgent',
|
||||
'AudioConfig',
|
||||
'OCRAgent',
|
||||
'OCRConfig',
|
||||
'ContextAgent',
|
||||
'create_context_agent',
|
||||
'Handoff',
|
||||
@@ -78,4 +88,5 @@ __all__ = [
|
||||
'ToolAwareAgentProtocol',
|
||||
'MemoryAwareAgentProtocol',
|
||||
'FullAgentProtocol',
|
||||
'ContextEngineerProtocol',
|
||||
]
|
||||
@@ -1,38 +1,28 @@
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import copy
|
||||
import logging
|
||||
import asyncio
|
||||
import threading
|
||||
from typing import List, Optional, Any, Dict, Union, Literal, TYPE_CHECKING, Callable, Tuple, Generator
|
||||
import contextlib
|
||||
from typing import List, Optional, Any, Dict, Union, Literal, TYPE_CHECKING, Callable, Generator
|
||||
from rich.console import Console
|
||||
from rich.live import Live
|
||||
from ..llm import (
|
||||
get_openai_client,
|
||||
ChatCompletionMessage,
|
||||
Choice,
|
||||
CompletionTokensDetails,
|
||||
PromptTokensDetails,
|
||||
CompletionUsage,
|
||||
ChatCompletion,
|
||||
ToolCall,
|
||||
process_stream_chunks
|
||||
)
|
||||
from ..main import (
|
||||
display_error,
|
||||
display_tool_call,
|
||||
display_instruction,
|
||||
display_interaction,
|
||||
display_generating,
|
||||
display_self_reflection,
|
||||
ReflectionOutput,
|
||||
adisplay_instruction,
|
||||
approval_callback,
|
||||
execute_sync_callback
|
||||
)
|
||||
import inspect
|
||||
import uuid
|
||||
|
||||
# Global variables for API server
|
||||
_server_started = {} # Dict of port -> started boolean
|
||||
@@ -43,11 +33,13 @@ _shared_apps = {} # Dict of port -> FastAPI app
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..task.task import Task
|
||||
from ..main import TaskOutput
|
||||
from .handoff import Handoff, HandoffConfig, HandoffResult
|
||||
from ..rag.models import RAGResult, ContextPack
|
||||
|
||||
class Agent:
|
||||
# Class-level counter for generating unique display names for nameless agents
|
||||
_agent_counter = 0
|
||||
|
||||
@classmethod
|
||||
def _configure_logging(cls):
|
||||
"""Configure logging settings once for all agent instances."""
|
||||
@@ -376,8 +368,8 @@ class Agent:
|
||||
# Add check at start if memory is requested
|
||||
if memory is not None:
|
||||
try:
|
||||
from ..memory.memory import Memory
|
||||
MEMORY_AVAILABLE = True
|
||||
from ..memory.memory import Memory # noqa: F401
|
||||
_ = Memory # Silence unused import warning - we just check availability
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"Memory features requested in Agent but memory dependencies not installed. "
|
||||
@@ -805,10 +797,14 @@ class Agent:
|
||||
# instructions like "You are a helpful assistant"
|
||||
if name:
|
||||
self.name = name
|
||||
self._agent_index = None # Named agents don't need an index
|
||||
else:
|
||||
# Don't auto-generate - None signals "no explicit name provided"
|
||||
# Display logic will skip Agent Info panel when name is None
|
||||
self.name = None
|
||||
# Assign unique index for display_name
|
||||
Agent._agent_counter += 1
|
||||
self._agent_index = Agent._agent_counter
|
||||
self.role = role or "Assistant"
|
||||
self.goal = goal or instructions
|
||||
self.backstory = backstory or instructions
|
||||
@@ -817,6 +813,7 @@ class Agent:
|
||||
else:
|
||||
# Use provided values or defaults
|
||||
self.name = name or "Agent"
|
||||
self._agent_index = None # Named agents don't need an index
|
||||
self.role = role or "Assistant"
|
||||
self.goal = goal or "Help the user with their tasks"
|
||||
self.backstory = backstory or "I am an AI assistant"
|
||||
@@ -841,7 +838,6 @@ class Agent:
|
||||
# LLM CONSOLIDATION: Handle model= alias and deprecation warnings
|
||||
# Precedence: llm= > model= > default
|
||||
# ============================================================
|
||||
import warnings
|
||||
|
||||
# Handle model= alias for llm= (NO warnings - both are valid)
|
||||
if llm is None and model is not None:
|
||||
@@ -1224,12 +1220,38 @@ Your Goal: {self.goal}
|
||||
agent_name=self.name or "Agent",
|
||||
)
|
||||
elif isinstance(self._context_param, ManagerConfig):
|
||||
# Use provided config
|
||||
# Use provided ManagerConfig
|
||||
self._context_manager = ContextManager(
|
||||
model=self.llm if isinstance(self.llm, str) else "gpt-4o-mini",
|
||||
config=self._context_param,
|
||||
agent_name=self.name or "Agent",
|
||||
)
|
||||
elif hasattr(self._context_param, 'auto_compact') and hasattr(self._context_param, 'tool_output_max'):
|
||||
# ContextConfig from YAML - convert to ManagerConfig
|
||||
try:
|
||||
from ..context.models import ContextConfig as _ContextConfig
|
||||
if isinstance(self._context_param, _ContextConfig):
|
||||
# Build ManagerConfig from ContextConfig fields
|
||||
manager_config = ManagerConfig(
|
||||
auto_compact=self._context_param.auto_compact,
|
||||
compact_threshold=self._context_param.compact_threshold,
|
||||
strategy=self._context_param.strategy,
|
||||
output_reserve=self._context_param.output_reserve,
|
||||
default_tool_output_max=self._context_param.tool_output_max, # Map field name
|
||||
protected_tools=list(self._context_param.protected_tools),
|
||||
keep_recent_turns=self._context_param.keep_recent_turns,
|
||||
monitor_enabled=self._context_param.monitor.enabled if self._context_param.monitor else False,
|
||||
)
|
||||
self._context_manager = ContextManager(
|
||||
model=self.llm if isinstance(self.llm, str) else "gpt-4o-mini",
|
||||
config=manager_config,
|
||||
agent_name=self.name or "Agent",
|
||||
)
|
||||
else:
|
||||
self._context_manager = None
|
||||
except Exception as e:
|
||||
logging.debug(f"ContextConfig conversion failed: {e}")
|
||||
self._context_manager = None
|
||||
elif hasattr(self._context_param, 'process'):
|
||||
# Already a ContextManager instance
|
||||
self._context_manager = self._context_param
|
||||
@@ -1350,6 +1372,20 @@ Your Goal: {self.goal}
|
||||
self._agent_id = str(uuid.uuid4())
|
||||
return self._agent_id
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
"""Safe display name that never returns None.
|
||||
|
||||
Returns the agent's name if set, otherwise returns 'Agent N' where N is a unique index.
|
||||
Use this for UI display, logging, and string operations where None would cause errors.
|
||||
"""
|
||||
if self.name:
|
||||
return self.name
|
||||
# Use unique index for nameless agents
|
||||
if hasattr(self, '_agent_index') and self._agent_index is not None:
|
||||
return f"Agent {self._agent_index}"
|
||||
return "Agent"
|
||||
|
||||
def _init_autonomy(self, autonomy: Any, verification_hooks: Optional[List[Any]] = None) -> None:
|
||||
"""Initialize autonomy features (agent-centric escalation/doom-loop).
|
||||
|
||||
@@ -1642,7 +1678,7 @@ Your Goal: {self.goal}
|
||||
print(result.response)
|
||||
```
|
||||
"""
|
||||
from .handoff import Handoff, HandoffConfig, HandoffResult
|
||||
from .handoff import Handoff, HandoffConfig
|
||||
|
||||
handoff_obj = Handoff(
|
||||
agent=target_agent,
|
||||
@@ -1679,7 +1715,7 @@ Your Goal: {self.goal}
|
||||
print(result.response)
|
||||
```
|
||||
"""
|
||||
from .handoff import Handoff, HandoffConfig, HandoffResult
|
||||
from .handoff import Handoff, HandoffConfig
|
||||
|
||||
handoff_obj = Handoff(
|
||||
agent=target_agent,
|
||||
@@ -1756,7 +1792,7 @@ Your Goal: {self.goal}
|
||||
return self.tools
|
||||
|
||||
# Filter to read-only tools only
|
||||
from ..planning import READ_ONLY_TOOLS, RESTRICTED_TOOLS
|
||||
from ..planning import RESTRICTED_TOOLS
|
||||
|
||||
filtered_tools = []
|
||||
for tool in self.tools:
|
||||
@@ -2666,7 +2702,98 @@ Your Goal: {self.goal}"""
|
||||
self._system_prompt_cache[cache_key] = system_prompt
|
||||
return system_prompt
|
||||
|
||||
def _build_messages(self, prompt, temperature=1.0, output_json=None, output_pydantic=None, tools=None):
|
||||
def _build_response_format(self, schema_model):
|
||||
"""Build response_format dict for native structured output.
|
||||
|
||||
Args:
|
||||
schema_model: Pydantic model or dict schema
|
||||
|
||||
Returns:
|
||||
Dict suitable for response_format parameter, or None if not applicable
|
||||
"""
|
||||
if not schema_model:
|
||||
return None
|
||||
|
||||
def _add_additional_properties_false(schema):
|
||||
"""Recursively add additionalProperties: false and required array to all object schemas."""
|
||||
if isinstance(schema, dict):
|
||||
if schema.get('type') == 'object':
|
||||
schema['additionalProperties'] = False
|
||||
# Add required array with all property keys (OpenAI strict mode requirement)
|
||||
if 'properties' in schema:
|
||||
schema['required'] = list(schema['properties'].keys())
|
||||
# Recurse into properties
|
||||
if 'properties' in schema:
|
||||
for prop in schema['properties'].values():
|
||||
_add_additional_properties_false(prop)
|
||||
# Recurse into items (for arrays)
|
||||
if 'items' in schema:
|
||||
_add_additional_properties_false(schema['items'])
|
||||
# Recurse into $defs
|
||||
if '$defs' in schema:
|
||||
for def_schema in schema['$defs'].values():
|
||||
_add_additional_properties_false(def_schema)
|
||||
return schema
|
||||
|
||||
def _wrap_array_in_object(schema):
|
||||
"""Wrap array schema in object since OpenAI requires root type to be object."""
|
||||
if schema.get('type') == 'array':
|
||||
return {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'items': schema
|
||||
},
|
||||
'required': ['items'],
|
||||
'additionalProperties': False
|
||||
}
|
||||
return schema
|
||||
|
||||
# Handle Pydantic model
|
||||
if hasattr(schema_model, 'model_json_schema'):
|
||||
schema = schema_model.model_json_schema()
|
||||
schema = _add_additional_properties_false(schema)
|
||||
schema = _wrap_array_in_object(schema)
|
||||
name = getattr(schema_model, '__name__', 'response')
|
||||
return {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": name,
|
||||
"schema": schema,
|
||||
"strict": True
|
||||
}
|
||||
}
|
||||
|
||||
# Handle dict schema (inline JSON schema from YAML)
|
||||
if isinstance(schema_model, dict):
|
||||
schema = schema_model.copy()
|
||||
schema = _add_additional_properties_false(schema)
|
||||
schema = _wrap_array_in_object(schema)
|
||||
return {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "structured_output",
|
||||
"schema": schema,
|
||||
"strict": True
|
||||
}
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
def _supports_native_structured_output(self):
|
||||
"""Check if current model supports native structured output via response_format.
|
||||
|
||||
Auto-detects based on model capabilities using LiteLLM.
|
||||
|
||||
Returns:
|
||||
bool: True if model supports response_format with json_schema
|
||||
"""
|
||||
try:
|
||||
from ..llm.model_capabilities import supports_structured_outputs
|
||||
return supports_structured_outputs(self.llm)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _build_messages(self, prompt, temperature=1.0, output_json=None, output_pydantic=None, tools=None, use_native_format=False):
|
||||
"""Build messages list for chat completion.
|
||||
|
||||
Args:
|
||||
@@ -2675,32 +2802,36 @@ Your Goal: {self.goal}"""
|
||||
output_json: Optional Pydantic model for JSON output
|
||||
output_pydantic: Optional Pydantic model for JSON output (alias)
|
||||
tools: Optional list of tools to use (defaults to self.tools)
|
||||
use_native_format: If True, skip text injection (native response_format will be used)
|
||||
|
||||
Returns:
|
||||
tuple: (messages list, original prompt)
|
||||
Tuple of (messages list, original prompt)
|
||||
"""
|
||||
# Build system prompt using the helper method
|
||||
system_prompt = self._build_system_prompt(tools)
|
||||
messages = []
|
||||
original_prompt = None
|
||||
|
||||
# Use openai_client's build_messages method if available
|
||||
if self._openai_client is not None:
|
||||
messages, original_prompt = self._openai_client.build_messages(
|
||||
prompt=prompt,
|
||||
system_prompt=system_prompt,
|
||||
system_prompt=self._build_system_prompt(
|
||||
tools=tools,
|
||||
),
|
||||
chat_history=self.chat_history,
|
||||
output_json=output_json,
|
||||
output_pydantic=output_pydantic
|
||||
output_json=None if use_native_format else output_json,
|
||||
output_pydantic=None if use_native_format else output_pydantic
|
||||
)
|
||||
else:
|
||||
# Fallback implementation for when OpenAI client is not available
|
||||
messages = []
|
||||
|
||||
# Add system message if provided
|
||||
# Build messages manually
|
||||
system_prompt = self._build_system_prompt(
|
||||
tools=tools,
|
||||
)
|
||||
if system_prompt:
|
||||
messages.append({"role": "system", "content": system_prompt})
|
||||
|
||||
# Add chat history
|
||||
messages.extend(self.chat_history)
|
||||
if self.chat_history:
|
||||
messages.extend(self.chat_history)
|
||||
|
||||
# Add user prompt
|
||||
if isinstance(prompt, list):
|
||||
@@ -2710,11 +2841,19 @@ Your Goal: {self.goal}"""
|
||||
messages.append({"role": "user", "content": str(prompt)})
|
||||
original_prompt = str(prompt)
|
||||
|
||||
# Add JSON format instruction if needed
|
||||
if output_json or output_pydantic:
|
||||
model = output_pydantic or output_json
|
||||
json_instruction = f"\nPlease respond with valid JSON matching this schema: {model.model_json_schema()}"
|
||||
messages[-1]["content"] += json_instruction
|
||||
# Add JSON format instruction if needed (only when not using native format)
|
||||
if not use_native_format and (output_json or output_pydantic):
|
||||
schema_model = output_pydantic or output_json
|
||||
# Handle Pydantic model
|
||||
if hasattr(schema_model, 'model_json_schema'):
|
||||
import json
|
||||
json_instruction = f"\nPlease respond with valid JSON matching this schema: {json.dumps(schema_model.model_json_schema())}"
|
||||
messages[-1]["content"] += json_instruction
|
||||
# Handle inline dict schema (Option A from YAML)
|
||||
elif isinstance(schema_model, dict):
|
||||
import json
|
||||
json_instruction = f"\nPlease respond with valid JSON matching this schema: {json.dumps(schema_model)}"
|
||||
messages[-1]["content"] += json_instruction
|
||||
|
||||
return messages, original_prompt
|
||||
|
||||
@@ -2889,7 +3028,7 @@ Your Goal: {self.goal}"""
|
||||
# Do NOT call it here to avoid duplicate output
|
||||
|
||||
# Set up injection context for tools with Injected parameters
|
||||
from ..tools.injected import AgentState, with_injection_context
|
||||
from ..tools.injected import AgentState
|
||||
state = AgentState(
|
||||
agent_id=self.name,
|
||||
run_id=getattr(self, '_current_run_id', 'unknown'),
|
||||
@@ -2902,17 +3041,34 @@ Your Goal: {self.goal}"""
|
||||
return self._execute_tool_with_context(function_name, arguments, state)
|
||||
|
||||
def _execute_tool_with_context(self, function_name, arguments, state):
|
||||
"""Execute tool within injection context."""
|
||||
"""Execute tool within injection context, with optional output truncation."""
|
||||
from ..tools.injected import with_injection_context
|
||||
|
||||
with with_injection_context(state):
|
||||
return self._execute_tool_impl(function_name, arguments)
|
||||
result = self._execute_tool_impl(function_name, arguments)
|
||||
|
||||
# Apply context-aware truncation if context management is enabled
|
||||
# This prevents tool outputs (e.g., search results) from exploding the context
|
||||
if self.context_manager and result:
|
||||
try:
|
||||
truncated = self._truncate_tool_output(function_name, str(result))
|
||||
if len(truncated) < len(str(result)):
|
||||
logging.debug(f"Truncated {function_name} output from {len(str(result))} to {len(truncated)} chars")
|
||||
# Return truncated string if significantly shorter
|
||||
if isinstance(result, dict):
|
||||
# Keep dict structure but may have truncated string representation
|
||||
return result # Let the string version be truncated at display time
|
||||
return truncated
|
||||
except Exception as e:
|
||||
logging.debug(f"Tool truncation skipped: {e}")
|
||||
|
||||
return result
|
||||
|
||||
def _execute_tool_impl(self, function_name, arguments):
|
||||
"""Internal tool execution implementation."""
|
||||
|
||||
# Check if approval is required for this tool
|
||||
from ..approval import is_approval_required, console_approval_callback, get_risk_level, mark_approved, ApprovalDecision, get_approval_callback
|
||||
from ..approval import is_approval_required, console_approval_callback, get_risk_level, mark_approved, get_approval_callback
|
||||
if is_approval_required(function_name):
|
||||
risk_level = get_risk_level(function_name)
|
||||
logging.debug(f"Tool {function_name} requires approval (risk level: {risk_level})")
|
||||
@@ -3071,6 +3227,160 @@ Your Goal: {self.goal}"""
|
||||
def clear_history(self):
|
||||
self.chat_history = []
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# History Management Methods
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def prune_history(self, keep_last: int = 5) -> int:
|
||||
"""
|
||||
Prune chat history to keep only the last N messages.
|
||||
|
||||
Useful for cleaning up large history after image analysis sessions
|
||||
to prevent context window saturation.
|
||||
|
||||
Args:
|
||||
keep_last: Number of recent messages to keep
|
||||
|
||||
Returns:
|
||||
Number of messages deleted
|
||||
"""
|
||||
with self._history_lock:
|
||||
if len(self.chat_history) <= keep_last:
|
||||
return 0
|
||||
|
||||
deleted_count = len(self.chat_history) - keep_last
|
||||
self.chat_history = self.chat_history[-keep_last:]
|
||||
return deleted_count
|
||||
|
||||
def delete_history(self, index: int) -> bool:
|
||||
"""
|
||||
Delete a specific message from chat history by index.
|
||||
|
||||
Supports negative indexing (-1 for last message, etc.).
|
||||
|
||||
Args:
|
||||
index: Message index (0-based, supports negative indexing)
|
||||
|
||||
Returns:
|
||||
True if deleted, False if index out of range
|
||||
"""
|
||||
with self._history_lock:
|
||||
try:
|
||||
del self.chat_history[index]
|
||||
return True
|
||||
except IndexError:
|
||||
return False
|
||||
|
||||
def delete_history_matching(self, pattern: str) -> int:
|
||||
"""
|
||||
Delete all messages matching a pattern.
|
||||
|
||||
Useful for removing all image-related messages after processing.
|
||||
|
||||
Args:
|
||||
pattern: Substring to match in message content
|
||||
|
||||
Returns:
|
||||
Number of messages deleted
|
||||
"""
|
||||
with self._history_lock:
|
||||
original_len = len(self.chat_history)
|
||||
self.chat_history = [
|
||||
msg for msg in self.chat_history
|
||||
if pattern.lower() not in msg.get("content", "").lower()
|
||||
]
|
||||
return original_len - len(self.chat_history)
|
||||
|
||||
def get_history_size(self) -> int:
|
||||
"""Get the current number of messages in chat history."""
|
||||
return len(self.chat_history)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def ephemeral(self):
|
||||
"""
|
||||
Context manager for ephemeral conversations.
|
||||
|
||||
Messages within this block are NOT permanently stored in chat_history.
|
||||
History is restored to pre-block state after exiting.
|
||||
|
||||
Example:
|
||||
with agent.ephemeral():
|
||||
response = agent.chat("[IMAGE] Analyze this")
|
||||
# After block, history is restored - image NOT persisted
|
||||
"""
|
||||
# Save current history state
|
||||
with self._history_lock:
|
||||
saved_history = self.chat_history.copy()
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
# Restore history to pre-block state
|
||||
with self._history_lock:
|
||||
self.chat_history = saved_history
|
||||
|
||||
def _build_multimodal_prompt(
|
||||
self,
|
||||
prompt: str,
|
||||
attachments: Optional[List[str]] = None
|
||||
) -> Union[str, List[Dict[str, Any]]]:
|
||||
"""
|
||||
Build a multimodal prompt from text and attachments.
|
||||
|
||||
This is a DRY helper used by chat/achat/run/arun/start/astart.
|
||||
Attachments are ephemeral - only text is stored in history.
|
||||
|
||||
Args:
|
||||
prompt: Text query (ALWAYS stored in chat_history)
|
||||
attachments: Image/file paths for THIS turn only (NEVER stored)
|
||||
|
||||
Returns:
|
||||
Either a string (no attachments) or multimodal message list
|
||||
"""
|
||||
if not attachments:
|
||||
return prompt
|
||||
|
||||
# Build multimodal content list
|
||||
content = [{"type": "text", "text": prompt}]
|
||||
|
||||
for attachment in attachments:
|
||||
# Handle image files
|
||||
if isinstance(attachment, str):
|
||||
import os
|
||||
import base64
|
||||
|
||||
if os.path.isfile(attachment):
|
||||
# File path - read and encode
|
||||
ext = os.path.splitext(attachment)[1].lower()
|
||||
if ext in ('.jpg', '.jpeg', '.png', '.gif', '.webp'):
|
||||
try:
|
||||
with open(attachment, 'rb') as f:
|
||||
data = base64.b64encode(f.read()).decode('utf-8')
|
||||
media_type = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
}.get(ext, 'image/jpeg')
|
||||
content.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:{media_type};base64,{data}"}
|
||||
})
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to load attachment {attachment}: {e}")
|
||||
elif attachment.startswith(('http://', 'https://', 'data:')):
|
||||
# URL or data URI
|
||||
content.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": attachment}
|
||||
})
|
||||
elif isinstance(attachment, dict):
|
||||
# Already structured content
|
||||
content.append(attachment)
|
||||
|
||||
return content
|
||||
|
||||
def __str__(self):
|
||||
return f"Agent(name='{self.name}', role='{self.role}', goal='{self.goal}')"
|
||||
|
||||
@@ -3090,7 +3400,7 @@ Your Goal: {self.goal}"""
|
||||
reasoning_steps=reasoning_steps
|
||||
)
|
||||
|
||||
def _chat_completion(self, messages, temperature=1.0, tools=None, stream=True, reasoning_steps=False, task_name=None, task_description=None, task_id=None):
|
||||
def _chat_completion(self, messages, temperature=1.0, tools=None, stream=True, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None):
|
||||
start_time = time.time()
|
||||
logging.debug(f"{self.name} sending messages to LLM: {messages}")
|
||||
|
||||
@@ -3177,19 +3487,24 @@ Your Goal: {self.goal}"""
|
||||
if self._openai_client is None:
|
||||
raise ValueError("OpenAI client is not initialized. Please provide OPENAI_API_KEY or use a custom LLM provider.")
|
||||
|
||||
final_response = self._openai_client.chat_completion_with_tools(
|
||||
messages=messages,
|
||||
model=self.llm,
|
||||
temperature=temperature,
|
||||
tools=formatted_tools, # Already formatted for OpenAI
|
||||
execute_tool_fn=self.execute_tool,
|
||||
stream=stream,
|
||||
console=self.console if (self.verbose or stream) else None,
|
||||
display_fn=self.display_generating if self.verbose else None,
|
||||
reasoning_steps=reasoning_steps,
|
||||
verbose=self.verbose,
|
||||
max_iterations=10
|
||||
)
|
||||
# Build kwargs including response_format if provided
|
||||
chat_kwargs = {
|
||||
"messages": messages,
|
||||
"model": self.llm,
|
||||
"temperature": temperature,
|
||||
"tools": formatted_tools, # Already formatted for OpenAI
|
||||
"execute_tool_fn": self.execute_tool,
|
||||
"stream": stream,
|
||||
"console": self.console if (self.verbose or stream) else None,
|
||||
"display_fn": self.display_generating if self.verbose else None,
|
||||
"reasoning_steps": reasoning_steps,
|
||||
"verbose": self.verbose,
|
||||
"max_iterations": 10
|
||||
}
|
||||
if response_format:
|
||||
chat_kwargs["response_format"] = response_format
|
||||
|
||||
final_response = self._openai_client.chat_completion_with_tools(**chat_kwargs)
|
||||
|
||||
return final_response
|
||||
|
||||
@@ -3471,11 +3786,25 @@ Your Goal: {self.goal}"""
|
||||
"""Get the current session ID."""
|
||||
return self._session_id
|
||||
|
||||
def chat(self, prompt, temperature=1.0, tools=None, output_json=None, output_pydantic=None, reasoning_steps=False, stream=None, task_name=None, task_description=None, task_id=None, config=None, force_retrieval=False, skip_retrieval=False):
|
||||
def chat(self, prompt, temperature=1.0, tools=None, output_json=None, output_pydantic=None, reasoning_steps=False, stream=None, task_name=None, task_description=None, task_id=None, config=None, force_retrieval=False, skip_retrieval=False, attachments=None):
|
||||
"""
|
||||
Chat with the agent.
|
||||
|
||||
Args:
|
||||
prompt: Text query that WILL be stored in chat_history
|
||||
attachments: Optional list of image/file paths that are ephemeral
|
||||
(used for THIS turn only, NEVER stored in history).
|
||||
Supports: file paths, URLs, or data URIs.
|
||||
...other args...
|
||||
"""
|
||||
# Apply rate limiter if configured (before any LLM call)
|
||||
if self._rate_limiter is not None:
|
||||
self._rate_limiter.acquire()
|
||||
|
||||
# Process ephemeral attachments (DRY - builds multimodal prompt)
|
||||
# IMPORTANT: Original text 'prompt' is stored in history, attachments are NOT
|
||||
llm_prompt = self._build_multimodal_prompt(prompt, attachments) if attachments else prompt
|
||||
|
||||
# Initialize DB session on first chat (lazy)
|
||||
self._init_db_session()
|
||||
|
||||
@@ -3674,8 +4003,23 @@ Your Goal: {self.goal}"""
|
||||
display_error(f"Error in LLM chat: {e}")
|
||||
return None
|
||||
else:
|
||||
# Determine if we should use native structured output
|
||||
schema_model = output_pydantic or output_json
|
||||
use_native_format = False
|
||||
response_format = None
|
||||
|
||||
if schema_model and self._supports_native_structured_output():
|
||||
# Model supports native structured output - build response_format
|
||||
response_format = self._build_response_format(schema_model)
|
||||
if response_format:
|
||||
use_native_format = True
|
||||
logging.debug(f"Agent {self.name} using native structured output with response_format")
|
||||
|
||||
# Use the new _build_messages helper method
|
||||
messages, original_prompt = self._build_messages(prompt, temperature, output_json, output_pydantic)
|
||||
messages, original_prompt = self._build_messages(
|
||||
prompt, temperature, output_json, output_pydantic,
|
||||
use_native_format=use_native_format
|
||||
)
|
||||
|
||||
# Store chat history length for potential rollback
|
||||
chat_history_length = len(self.chat_history)
|
||||
@@ -3731,7 +4075,7 @@ Your Goal: {self.goal}"""
|
||||
agent_tools=agent_tools
|
||||
)
|
||||
|
||||
response = self._chat_completion(messages, temperature=temperature, tools=tools if tools else None, reasoning_steps=reasoning_steps, stream=stream, task_name=task_name, task_description=task_description, task_id=task_id)
|
||||
response = self._chat_completion(messages, temperature=temperature, tools=tools if tools else None, reasoning_steps=reasoning_steps, stream=stream, task_name=task_name, task_description=task_description, task_id=task_id, response_format=response_format)
|
||||
if not response:
|
||||
# Rollback chat history on response failure
|
||||
self.chat_history = self.chat_history[:chat_history_length]
|
||||
@@ -3916,8 +4260,18 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
cleaned = cleaned[:-3].strip()
|
||||
return cleaned
|
||||
|
||||
async def achat(self, prompt: str, temperature=1.0, tools=None, output_json=None, output_pydantic=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None):
|
||||
"""Async version of chat method with self-reflection support."""
|
||||
async def achat(self, prompt: str, temperature=1.0, tools=None, output_json=None, output_pydantic=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, attachments=None):
|
||||
"""Async version of chat method with self-reflection support.
|
||||
|
||||
Args:
|
||||
prompt: Text query that WILL be stored in chat_history
|
||||
attachments: Optional list of image/file paths that are ephemeral
|
||||
(used for THIS turn only, NEVER stored in history).
|
||||
"""
|
||||
# Process ephemeral attachments (DRY - builds multimodal prompt)
|
||||
# IMPORTANT: Original text 'prompt' is stored in history, attachments are NOT
|
||||
llm_prompt = self._build_multimodal_prompt(prompt, attachments) if attachments else prompt
|
||||
|
||||
# Track execution via telemetry
|
||||
if hasattr(self, '_telemetry') and self._telemetry:
|
||||
self._telemetry.track_agent_execution(self.name, success=True)
|
||||
@@ -4384,6 +4738,11 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
if 'stream' not in kwargs:
|
||||
kwargs['stream'] = False
|
||||
|
||||
# Substitute dynamic variables ({{today}}, {{now}}, {{uuid}}, etc.)
|
||||
if prompt and "{{" in prompt:
|
||||
from praisonaiagents.utils.variables import substitute_variables
|
||||
prompt = substitute_variables(prompt, {})
|
||||
|
||||
# Load history context
|
||||
self._load_history_context()
|
||||
|
||||
@@ -4571,6 +4930,11 @@ Write the complete compiled report:"""
|
||||
if prompt is None:
|
||||
prompt = self.instructions or "Hello"
|
||||
|
||||
# Substitute dynamic variables ({{today}}, {{now}}, {{uuid}}, etc.)
|
||||
if prompt and "{{" in prompt:
|
||||
from praisonaiagents.utils.variables import substitute_variables
|
||||
prompt = substitute_variables(prompt, {})
|
||||
|
||||
# Load history from past sessions
|
||||
self._load_history_context()
|
||||
|
||||
@@ -4627,9 +4991,6 @@ Write the complete compiled report:"""
|
||||
|
||||
# Show animated status during LLM call if verbose
|
||||
if self.verbose and is_tty:
|
||||
from rich.live import Live
|
||||
from rich.console import Group
|
||||
from rich.status import Status
|
||||
from ..main import PRAISON_COLORS, sync_display_callbacks
|
||||
import threading
|
||||
import time as time_module
|
||||
@@ -5291,7 +5652,7 @@ Write the complete compiled report:"""
|
||||
if "query" not in request_data:
|
||||
raise HTTPException(status_code=400, detail="Missing 'query' field in request")
|
||||
query = request_data["query"]
|
||||
except:
|
||||
except Exception:
|
||||
# Fallback to form data or query params
|
||||
form_data = await request.form()
|
||||
if "query" in form_data:
|
||||
@@ -5395,7 +5756,7 @@ Write the complete compiled report:"""
|
||||
from starlette.applications import Starlette
|
||||
from starlette.requests import Request
|
||||
from starlette.routing import Mount, Route
|
||||
from mcp.server import Server as MCPServer # Alias to avoid conflict
|
||||
from mcp.server import Server as MCPServer # noqa: F401 - imported for availability check
|
||||
import threading
|
||||
import time
|
||||
import inspect
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
"""
|
||||
AudioAgent - A specialized agent class for audio processing using AI models.
|
||||
Provides Text-to-Speech (TTS) and Speech-to-Text (STT/Transcription) capabilities.
|
||||
|
||||
Follows the Agent() class patterns:
|
||||
- Precedence Ladder: Instance > Config > Array > Dict > String > Bool > Default
|
||||
- Lazy imports for LiteLLM (zero overhead until first use)
|
||||
- Async-safe with both sync and async methods
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
import warnings
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Any, Dict, Union, BinaryIO
|
||||
from pathlib import Path
|
||||
|
||||
# Filter out Pydantic warning about fields
|
||||
warnings.filterwarnings("ignore", "Valid config keys have changed in V2", UserWarning)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# AudioConfig - Configuration dataclass following feature_configs.py patterns
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class AudioConfig:
|
||||
"""
|
||||
Configuration for audio processing settings.
|
||||
|
||||
Follows the Precedence Ladder pattern:
|
||||
- Instance > Config > Array > Dict > String > Bool > Default
|
||||
"""
|
||||
# TTS settings
|
||||
voice: Optional[str] = "alloy"
|
||||
speed: float = 1.0
|
||||
response_format: str = "mp3" # mp3, opus, aac, flac, wav, pcm
|
||||
|
||||
# STT settings
|
||||
language: Optional[str] = None
|
||||
temperature: float = 0.0
|
||||
|
||||
# Common settings
|
||||
timeout: int = 600
|
||||
|
||||
# API configuration
|
||||
api_base: Optional[str] = None
|
||||
api_key: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for LiteLLM calls."""
|
||||
return {
|
||||
"voice": self.voice,
|
||||
"speed": self.speed,
|
||||
"response_format": self.response_format,
|
||||
"language": self.language,
|
||||
"temperature": self.temperature,
|
||||
"timeout": self.timeout,
|
||||
"api_base": self.api_base,
|
||||
"api_key": self.api_key,
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# AudioAgent Class - Agent-centric audio processing
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class AudioAgent:
|
||||
"""
|
||||
A specialized agent for audio processing using AI models.
|
||||
|
||||
Provides:
|
||||
- Text-to-Speech (TTS): Convert text to spoken audio
|
||||
- Speech-to-Text (STT): Transcribe audio to text
|
||||
|
||||
TTS Providers:
|
||||
- OpenAI: `openai/tts-1`, `openai/tts-1-hd`
|
||||
- Azure: `azure/tts-1`
|
||||
- Gemini: `gemini/gemini-2.5-flash-preview-tts`
|
||||
- Vertex AI: `vertex_ai/gemini-2.5-flash-preview-tts`
|
||||
- ElevenLabs: `elevenlabs/eleven_multilingual_v2`
|
||||
- MiniMax: `minimax/speech-01`
|
||||
|
||||
STT Providers:
|
||||
- OpenAI: `openai/whisper-1`
|
||||
- Azure: `azure/whisper`
|
||||
- Groq: `groq/whisper-large-v3`
|
||||
- Deepgram: `deepgram/nova-2`
|
||||
- Gemini: `gemini/gemini-2.0-flash`
|
||||
|
||||
Example:
|
||||
```python
|
||||
from praisonaiagents import AudioAgent
|
||||
|
||||
# Text-to-Speech
|
||||
agent = AudioAgent(llm="openai/tts-1")
|
||||
agent.speech("Hello world!", output="hello.mp3")
|
||||
|
||||
# Speech-to-Text
|
||||
agent = AudioAgent(llm="openai/whisper-1")
|
||||
text = agent.transcribe("audio.mp3")
|
||||
print(text)
|
||||
```
|
||||
"""
|
||||
|
||||
# Default models
|
||||
DEFAULT_TTS_MODEL = "openai/tts-1"
|
||||
DEFAULT_STT_MODEL = "openai/whisper-1"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
# Core identity
|
||||
name: Optional[str] = None,
|
||||
instructions: Optional[str] = None,
|
||||
# LLM configuration
|
||||
llm: Optional[str] = None,
|
||||
model: Optional[str] = None, # Alias for llm=
|
||||
# Connection/auth
|
||||
base_url: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
# Audio-specific configuration
|
||||
audio: Optional[Union[bool, Dict, "AudioConfig"]] = None,
|
||||
# Output configuration
|
||||
verbose: Union[bool, int] = True,
|
||||
):
|
||||
"""Initialize AudioAgent.
|
||||
|
||||
Args:
|
||||
name: Agent name for identification
|
||||
instructions: Optional instructions
|
||||
llm: Model name (e.g., "openai/tts-1", "openai/whisper-1")
|
||||
model: Alias for llm= parameter
|
||||
base_url: Custom API endpoint URL
|
||||
api_key: API key for the provider
|
||||
audio: Audio configuration. Accepts:
|
||||
- bool: True enables with defaults
|
||||
- dict: {"voice": "alloy", "speed": 1.0}
|
||||
- AudioConfig: Full configuration object
|
||||
verbose: Verbosity level for output
|
||||
"""
|
||||
# Handle model= alias
|
||||
if llm is None and model is not None:
|
||||
llm = model
|
||||
|
||||
self.name = name or "AudioAgent"
|
||||
self.instructions = instructions
|
||||
self.llm = llm # Can be None - will use defaults based on method
|
||||
self.base_url = base_url
|
||||
self.api_key = api_key
|
||||
self._audio_config = self._resolve_audio_config(audio)
|
||||
self.verbose = verbose
|
||||
|
||||
# Lazy load LiteLLM
|
||||
self._litellm = None
|
||||
self._console = None
|
||||
|
||||
self._configure_logging(verbose)
|
||||
|
||||
def _resolve_audio_config(self, audio: Optional[Union[bool, Dict, AudioConfig]]) -> AudioConfig:
|
||||
"""Resolve audio parameter using Precedence Ladder."""
|
||||
if audio is None or audio is True or audio is False:
|
||||
return AudioConfig()
|
||||
elif isinstance(audio, AudioConfig):
|
||||
return audio
|
||||
elif isinstance(audio, dict):
|
||||
return AudioConfig(**audio)
|
||||
return AudioConfig()
|
||||
|
||||
@property
|
||||
def console(self):
|
||||
"""Lazily initialize Rich Console."""
|
||||
if self._console is None:
|
||||
from rich.console import Console
|
||||
self._console = Console()
|
||||
return self._console
|
||||
|
||||
@property
|
||||
def litellm(self):
|
||||
"""Lazy load litellm module when needed."""
|
||||
if self._litellm is None:
|
||||
try:
|
||||
import litellm
|
||||
litellm.telemetry = False
|
||||
litellm.success_callback = []
|
||||
self._litellm = litellm
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"litellm is required for audio processing. "
|
||||
"Please install with: pip install litellm"
|
||||
)
|
||||
return self._litellm
|
||||
|
||||
def _configure_logging(self, verbose: Union[bool, int]) -> None:
|
||||
"""Configure logging levels."""
|
||||
if isinstance(verbose, int) and verbose >= 10:
|
||||
logging.getLogger("litellm").setLevel(logging.DEBUG)
|
||||
else:
|
||||
logging.getLogger("litellm").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
|
||||
def _get_model_params(self, model: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Build parameters for LiteLLM calls."""
|
||||
params = {"model": model or self.llm}
|
||||
if self.api_key:
|
||||
params["api_key"] = self.api_key
|
||||
if self.base_url:
|
||||
params["api_base"] = self.base_url
|
||||
return params
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Text-to-Speech (TTS)
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def speech(
|
||||
self,
|
||||
text: str,
|
||||
output: Optional[str] = None,
|
||||
voice: Optional[str] = None,
|
||||
speed: Optional[float] = None,
|
||||
response_format: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> Any:
|
||||
"""
|
||||
Convert text to speech.
|
||||
|
||||
Args:
|
||||
text: Text to convert to speech
|
||||
output: Path to save audio file (optional)
|
||||
voice: Voice to use (e.g., "alloy", "echo", "fable")
|
||||
speed: Speech speed (0.25 to 4.0)
|
||||
response_format: Audio format (mp3, opus, aac, flac, wav)
|
||||
model: Override model for this call
|
||||
**kwargs: Additional provider-specific parameters
|
||||
|
||||
Returns:
|
||||
Audio response object with stream_to_file() method
|
||||
|
||||
Example:
|
||||
```python
|
||||
agent = AudioAgent(llm="openai/tts-1")
|
||||
agent.speech("Hello world!", output="hello.mp3")
|
||||
```
|
||||
"""
|
||||
# Resolve model
|
||||
model = model or self.llm or self.DEFAULT_TTS_MODEL
|
||||
|
||||
# Build params
|
||||
params = self._get_model_params(model)
|
||||
params["input"] = text
|
||||
params["voice"] = voice or self._audio_config.voice
|
||||
|
||||
if speed is not None:
|
||||
params["speed"] = speed
|
||||
elif self._audio_config.speed != 1.0:
|
||||
params["speed"] = self._audio_config.speed
|
||||
|
||||
if response_format:
|
||||
params["response_format"] = response_format
|
||||
elif self._audio_config.response_format != "mp3":
|
||||
params["response_format"] = self._audio_config.response_format
|
||||
|
||||
params.update(kwargs)
|
||||
|
||||
if self.verbose:
|
||||
self.console.print(f"[cyan]Generating speech with {model}...[/cyan]")
|
||||
|
||||
response = self.litellm.speech(**params)
|
||||
|
||||
if output:
|
||||
response.stream_to_file(Path(output))
|
||||
if self.verbose:
|
||||
self.console.print(f"[green]✓ Audio saved to {output}[/green]")
|
||||
|
||||
return response
|
||||
|
||||
async def aspeech(
|
||||
self,
|
||||
text: str,
|
||||
output: Optional[str] = None,
|
||||
voice: Optional[str] = None,
|
||||
speed: Optional[float] = None,
|
||||
response_format: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> Any:
|
||||
"""Async version of speech()."""
|
||||
model = model or self.llm or self.DEFAULT_TTS_MODEL
|
||||
|
||||
params = self._get_model_params(model)
|
||||
params["input"] = text
|
||||
params["voice"] = voice or self._audio_config.voice
|
||||
|
||||
if speed is not None:
|
||||
params["speed"] = speed
|
||||
elif self._audio_config.speed != 1.0:
|
||||
params["speed"] = self._audio_config.speed
|
||||
|
||||
if response_format:
|
||||
params["response_format"] = response_format
|
||||
|
||||
params.update(kwargs)
|
||||
|
||||
response = await self.litellm.aspeech(**params)
|
||||
|
||||
if output:
|
||||
response.stream_to_file(Path(output))
|
||||
|
||||
return response
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Speech-to-Text (STT / Transcription)
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def transcribe(
|
||||
self,
|
||||
file: Union[str, BinaryIO],
|
||||
language: Optional[str] = None,
|
||||
temperature: Optional[float] = None,
|
||||
model: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> str:
|
||||
"""
|
||||
Transcribe audio to text.
|
||||
|
||||
Args:
|
||||
file: Path to audio file or file-like object
|
||||
language: Language code (e.g., "en", "es", "fr")
|
||||
temperature: Sampling temperature (0.0 to 1.0)
|
||||
model: Override model for this call
|
||||
**kwargs: Additional provider-specific parameters
|
||||
|
||||
Returns:
|
||||
Transcribed text
|
||||
|
||||
Example:
|
||||
```python
|
||||
agent = AudioAgent(llm="openai/whisper-1")
|
||||
text = agent.transcribe("audio.mp3")
|
||||
print(text)
|
||||
```
|
||||
"""
|
||||
model = model or self.llm or self.DEFAULT_STT_MODEL
|
||||
|
||||
params = self._get_model_params(model)
|
||||
|
||||
# Handle file input
|
||||
if isinstance(file, str):
|
||||
params["file"] = open(file, "rb")
|
||||
else:
|
||||
params["file"] = file
|
||||
|
||||
if language:
|
||||
params["language"] = language
|
||||
elif self._audio_config.language:
|
||||
params["language"] = self._audio_config.language
|
||||
|
||||
if temperature is not None:
|
||||
params["temperature"] = temperature
|
||||
elif self._audio_config.temperature != 0.0:
|
||||
params["temperature"] = self._audio_config.temperature
|
||||
|
||||
params.update(kwargs)
|
||||
|
||||
if self.verbose:
|
||||
self.console.print(f"[cyan]Transcribing with {model}...[/cyan]")
|
||||
|
||||
response = self.litellm.transcription(**params)
|
||||
|
||||
# Close file if we opened it
|
||||
if isinstance(file, str):
|
||||
params["file"].close()
|
||||
|
||||
text = response.text if hasattr(response, 'text') else str(response)
|
||||
|
||||
if self.verbose:
|
||||
self.console.print(f"[green]✓ Transcription complete[/green]")
|
||||
|
||||
return text
|
||||
|
||||
async def atranscribe(
|
||||
self,
|
||||
file: Union[str, BinaryIO],
|
||||
language: Optional[str] = None,
|
||||
temperature: Optional[float] = None,
|
||||
model: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> str:
|
||||
"""Async version of transcribe()."""
|
||||
model = model or self.llm or self.DEFAULT_STT_MODEL
|
||||
|
||||
params = self._get_model_params(model)
|
||||
|
||||
if isinstance(file, str):
|
||||
params["file"] = open(file, "rb")
|
||||
else:
|
||||
params["file"] = file
|
||||
|
||||
if language:
|
||||
params["language"] = language
|
||||
elif self._audio_config.language:
|
||||
params["language"] = self._audio_config.language
|
||||
|
||||
if temperature is not None:
|
||||
params["temperature"] = temperature
|
||||
|
||||
params.update(kwargs)
|
||||
|
||||
response = await self.litellm.atranscription(**params)
|
||||
|
||||
if isinstance(file, str):
|
||||
params["file"].close()
|
||||
|
||||
return response.text if hasattr(response, 'text') else str(response)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Convenience Methods
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def say(self, text: str, output: str = "output.mp3", **kwargs) -> str:
|
||||
"""
|
||||
Quick TTS - convert text and save to file.
|
||||
|
||||
Args:
|
||||
text: Text to speak
|
||||
output: Output filename (default: output.mp3)
|
||||
|
||||
Returns:
|
||||
Path to saved file
|
||||
"""
|
||||
self.speech(text, output=output, **kwargs)
|
||||
return output
|
||||
|
||||
async def asay(self, text: str, output: str = "output.mp3", **kwargs) -> str:
|
||||
"""Async version of say()."""
|
||||
await self.aspeech(text, output=output, **kwargs)
|
||||
return output
|
||||
|
||||
def listen(self, file: Union[str, BinaryIO], **kwargs) -> str:
|
||||
"""
|
||||
Quick STT - transcribe audio file.
|
||||
|
||||
Args:
|
||||
file: Audio file to transcribe
|
||||
|
||||
Returns:
|
||||
Transcribed text
|
||||
"""
|
||||
return self.transcribe(file, **kwargs)
|
||||
|
||||
async def alisten(self, file: Union[str, BinaryIO], **kwargs) -> str:
|
||||
"""Async version of listen()."""
|
||||
return await self.atranscribe(file, **kwargs)
|
||||
@@ -13,17 +13,88 @@ This class implements proper Context Engineering principles following the PRD te
|
||||
|
||||
import os
|
||||
import json
|
||||
import asyncio
|
||||
import glob
|
||||
import subprocess
|
||||
import ast
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, Any, Dict, Union, List
|
||||
from typing import Optional, Any, Dict, Union, List, TYPE_CHECKING
|
||||
|
||||
# Lazy imports for performance - these are only loaded when needed
|
||||
_subprocess = None
|
||||
_glob = None
|
||||
_ast = None
|
||||
_asyncio = None
|
||||
|
||||
def _get_subprocess():
|
||||
"""Lazy import subprocess to avoid import-time overhead."""
|
||||
global _subprocess
|
||||
if _subprocess is None:
|
||||
import subprocess
|
||||
_subprocess = subprocess
|
||||
return _subprocess
|
||||
|
||||
def _get_glob():
|
||||
"""Lazy import glob to avoid import-time overhead."""
|
||||
global _glob
|
||||
if _glob is None:
|
||||
import glob
|
||||
_glob = glob
|
||||
return _glob
|
||||
|
||||
def _get_ast():
|
||||
"""Lazy import ast to avoid import-time overhead."""
|
||||
global _ast
|
||||
if _ast is None:
|
||||
import ast
|
||||
_ast = ast
|
||||
return _ast
|
||||
|
||||
def _get_asyncio():
|
||||
"""Lazy import asyncio to avoid import-time overhead."""
|
||||
global _asyncio
|
||||
if _asyncio is None:
|
||||
import asyncio
|
||||
_asyncio = asyncio
|
||||
return _asyncio
|
||||
|
||||
async def _async_subprocess_run(cmd: list, timeout: int = 60) -> tuple:
|
||||
"""
|
||||
Run subprocess asynchronously (non-blocking).
|
||||
|
||||
Args:
|
||||
cmd: Command list to execute
|
||||
timeout: Timeout in seconds
|
||||
|
||||
Returns:
|
||||
Tuple of (stdout, stderr, returncode)
|
||||
"""
|
||||
asyncio = _get_asyncio()
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(),
|
||||
timeout=timeout
|
||||
)
|
||||
return (
|
||||
stdout.decode() if stdout else "",
|
||||
stderr.decode() if stderr else "",
|
||||
process.returncode
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
if process:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
raise
|
||||
except FileNotFoundError:
|
||||
raise
|
||||
|
||||
from ..agent.agent import Agent
|
||||
from ..task import Task
|
||||
|
||||
|
||||
class ContextAgent(Agent):
|
||||
"""
|
||||
Advanced Context Engineering Agent - Comprehensive context generation for AI coding assistants.
|
||||
@@ -69,6 +140,7 @@ class ContextAgent(Agent):
|
||||
deeply and generate actionable implementation guidance."""
|
||||
|
||||
# Initialize parent Agent
|
||||
# Note: self.verbose is set by parent Agent based on output= parameter
|
||||
super().__init__(
|
||||
name=default_name,
|
||||
role=default_role,
|
||||
@@ -102,6 +174,34 @@ class ContextAgent(Agent):
|
||||
if self.auto_analyze and self.project_path:
|
||||
self._perform_context_engineering_analysis()
|
||||
|
||||
def _log(self, message: str, level: str = "info"):
|
||||
"""
|
||||
Configurable logging that respects verbose flag.
|
||||
|
||||
Args:
|
||||
message: Message to log
|
||||
level: Log level (info, debug, warning, error)
|
||||
"""
|
||||
if level == "debug":
|
||||
if hasattr(self, 'logger'):
|
||||
self.logger.debug(message)
|
||||
if self.debug_mode:
|
||||
print(f"🐛 {message}")
|
||||
elif level == "warning":
|
||||
if hasattr(self, 'logger'):
|
||||
self.logger.warning(message)
|
||||
if self.verbose:
|
||||
print(f"⚠️ {message}")
|
||||
elif level == "error":
|
||||
if hasattr(self, 'logger'):
|
||||
self.logger.error(message)
|
||||
print(f"❌ {message}") # Always show errors
|
||||
else: # info
|
||||
if hasattr(self, 'logger'):
|
||||
self.logger.info(message)
|
||||
if self.verbose:
|
||||
print(message)
|
||||
|
||||
def setup_logging(self):
|
||||
"""Setup comprehensive logging based on debug mode."""
|
||||
try:
|
||||
@@ -474,7 +574,7 @@ codebase style and architecture following PRD template principles."""
|
||||
"""Run gitingest analysis on the codebase."""
|
||||
try:
|
||||
# Try to run gitingest command
|
||||
result = subprocess.run(
|
||||
result = _get_subprocess().run(
|
||||
["gitingest", project_path, "--output", "-"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
@@ -487,7 +587,7 @@ codebase style and architecture following PRD template principles."""
|
||||
print(f" ⚠️ Gitingest command failed: {result.stderr}")
|
||||
return None
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
except _get_subprocess().TimeoutExpired:
|
||||
print(" ⚠️ Gitingest analysis timed out")
|
||||
return None
|
||||
except FileNotFoundError:
|
||||
@@ -1030,7 +1130,7 @@ detailed, actionable implementation steps."""
|
||||
def _get_filtered_files(self, project_path: str, pattern: str, max_files: int) -> List[str]:
|
||||
"""Get filtered files excluding unwanted directories following PRD principles."""
|
||||
try:
|
||||
files = glob.glob(os.path.join(project_path, "**", pattern), recursive=True)
|
||||
files = _get_glob().glob(os.path.join(project_path, "**", pattern), recursive=True)
|
||||
|
||||
# Filter out unwanted files following PRD methodology
|
||||
filtered = []
|
||||
@@ -1403,7 +1503,7 @@ Return in the exact format specified in your instructions."""
|
||||
"""Get basic repository file structure."""
|
||||
try:
|
||||
# Try using gitingest with structure-only flag first
|
||||
result = subprocess.run(
|
||||
result = _get_subprocess().run(
|
||||
["gitingest", github_url, "--tree-only", "--output", "-"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
@@ -1421,7 +1521,7 @@ Return in the exact format specified in your instructions."""
|
||||
print(f" ⚠️ Gitingest tree failed, using GitHub API...")
|
||||
return self._get_structure_fallback(github_url)
|
||||
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError) as e:
|
||||
except (_get_subprocess().TimeoutExpired, FileNotFoundError) as e:
|
||||
print(f" ⚠️ Gitingest not available: {e}, using GitHub API...")
|
||||
return self._get_structure_fallback(github_url)
|
||||
|
||||
@@ -1626,7 +1726,7 @@ Focus on creating clear, structured metadata that will help with intelligent fil
|
||||
print(" ⚠️ gitingest Python package not available, using command line...")
|
||||
|
||||
# Fallback to command line gitingest for actual content
|
||||
result = subprocess.run(
|
||||
result = _get_subprocess().run(
|
||||
["gitingest", github_url, "--output", "-"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
@@ -1946,7 +2046,7 @@ Since these files were pre-selected for relevance, explain how each contributes
|
||||
except ImportError:
|
||||
print(f" ⚠️ gitingest Python package not available, trying command line...")
|
||||
# Fallback to command line gitingest
|
||||
result = subprocess.run(
|
||||
result = _get_subprocess().run(
|
||||
["gitingest", github_url, "--output", "-"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
@@ -1959,7 +2059,7 @@ Since these files were pre-selected for relevance, explain how each contributes
|
||||
print(f" ⚠️ Gitingest command failed: {result.stderr}")
|
||||
return None
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
except _get_subprocess().TimeoutExpired:
|
||||
print(" ⚠️ Gitingest analysis timed out")
|
||||
return None
|
||||
except FileNotFoundError:
|
||||
@@ -2282,6 +2382,105 @@ This PRP provides:
|
||||
"success": False
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# P1: Protocol-Compatible Aliases (ContextEngineerProtocol compliance)
|
||||
# ============================================================================
|
||||
|
||||
def analyze_codebase(self, project_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Protocol-compatible alias for analyze_codebase_with_gitingest.
|
||||
|
||||
Args:
|
||||
project_path: Path to the project directory
|
||||
|
||||
Returns:
|
||||
Analysis results including patterns, architecture, conventions
|
||||
"""
|
||||
return self.analyze_codebase_with_gitingest(project_path)
|
||||
|
||||
def generate_prp(
|
||||
self,
|
||||
feature_request: str,
|
||||
context_analysis: Optional[Dict[str, Any]] = None
|
||||
) -> str:
|
||||
"""
|
||||
Protocol-compatible alias for generate_comprehensive_prp.
|
||||
|
||||
Args:
|
||||
feature_request: Description of the feature to implement
|
||||
context_analysis: Optional codebase analysis to include
|
||||
|
||||
Returns:
|
||||
Comprehensive PRP document as string
|
||||
"""
|
||||
return self.generate_comprehensive_prp(feature_request, context_analysis)
|
||||
|
||||
def create_implementation_blueprint(
|
||||
self,
|
||||
feature_request: str,
|
||||
context_analysis: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Protocol-compatible alias for build_implementation_blueprint.
|
||||
|
||||
Args:
|
||||
feature_request: Description of the feature
|
||||
context_analysis: Optional codebase analysis
|
||||
|
||||
Returns:
|
||||
Blueprint with implementation steps, files to modify, etc.
|
||||
"""
|
||||
return self.build_implementation_blueprint(feature_request, context_analysis)
|
||||
|
||||
# ============================================================================
|
||||
# P3: Async Methods (ContextEngineerProtocol compliance)
|
||||
# ============================================================================
|
||||
|
||||
async def aanalyze_codebase(self, project_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Async version of analyze_codebase.
|
||||
|
||||
Uses async subprocess for non-blocking execution.
|
||||
"""
|
||||
asyncio = _get_asyncio()
|
||||
# Run sync method in executor for now (subprocess calls are blocking)
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
self.analyze_codebase_with_gitingest,
|
||||
project_path
|
||||
)
|
||||
|
||||
async def agenerate_prp(
|
||||
self,
|
||||
feature_request: str,
|
||||
context_analysis: Optional[Dict[str, Any]] = None
|
||||
) -> str:
|
||||
"""
|
||||
Async version of generate_prp.
|
||||
"""
|
||||
asyncio = _get_asyncio()
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.generate_comprehensive_prp(feature_request, context_analysis)
|
||||
)
|
||||
|
||||
async def acreate_implementation_blueprint(
|
||||
self,
|
||||
feature_request: str,
|
||||
context_analysis: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Async version of create_implementation_blueprint.
|
||||
"""
|
||||
asyncio = _get_asyncio()
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.build_implementation_blueprint(feature_request, context_analysis)
|
||||
)
|
||||
|
||||
|
||||
def create_context_agent(llm: Optional[Union[str, Any]] = None, **kwargs) -> ContextAgent:
|
||||
"""
|
||||
|
||||
@@ -487,22 +487,85 @@ You are a professional research analyst. When conducting research:
|
||||
raw_response=response
|
||||
)
|
||||
|
||||
def _parse_gemini_response(self, interaction: Any) -> DeepResearchResponse:
|
||||
"""Parse Gemini Deep Research API response."""
|
||||
def _parse_gemini_response(
|
||||
self,
|
||||
interaction: Any,
|
||||
fallback_text: str = "",
|
||||
fallback_reasoning: Optional[List[ReasoningStep]] = None
|
||||
) -> DeepResearchResponse:
|
||||
"""Parse Gemini Deep Research API response.
|
||||
|
||||
Args:
|
||||
interaction: The Gemini interaction object
|
||||
fallback_text: Accumulated text from streaming (used if outputs parsing fails)
|
||||
fallback_reasoning: Reasoning steps collected during streaming
|
||||
"""
|
||||
report = ""
|
||||
citations = []
|
||||
reasoning_steps = []
|
||||
reasoning_steps = fallback_reasoning or []
|
||||
|
||||
# Get the final output
|
||||
# Try multiple attribute paths for Gemini output structure
|
||||
# Path 1: Direct outputs with text attribute
|
||||
if hasattr(interaction, 'outputs') and interaction.outputs:
|
||||
last_output = interaction.outputs[-1]
|
||||
if hasattr(last_output, 'text'):
|
||||
if hasattr(last_output, 'text') and last_output.text:
|
||||
report = last_output.text
|
||||
elif hasattr(last_output, 'content'):
|
||||
report = str(last_output.content)
|
||||
# Gemini nested structure: content.parts[0].text
|
||||
content = last_output.content
|
||||
if hasattr(content, 'parts') and content.parts:
|
||||
first_part = content.parts[0]
|
||||
if hasattr(first_part, 'text'):
|
||||
report = first_part.text
|
||||
else:
|
||||
report = str(first_part)
|
||||
elif hasattr(content, 'text'):
|
||||
report = content.text
|
||||
else:
|
||||
report = str(content)
|
||||
|
||||
# Gemini doesn't provide structured citations in the same way
|
||||
# but we can try to extract them from annotations if available
|
||||
# Path 2: Direct result attribute
|
||||
if not report and hasattr(interaction, 'result'):
|
||||
result = interaction.result
|
||||
if hasattr(result, 'text'):
|
||||
report = result.text
|
||||
else:
|
||||
report = str(result)
|
||||
|
||||
# Path 3: Response attribute
|
||||
if not report and hasattr(interaction, 'response'):
|
||||
resp = interaction.response
|
||||
if hasattr(resp, 'text'):
|
||||
report = resp.text
|
||||
elif hasattr(resp, 'content'):
|
||||
report = str(resp.content)
|
||||
|
||||
# Path 4: Fallback to streamed content (critical fix)
|
||||
if not report and fallback_text:
|
||||
report = fallback_text
|
||||
if self.verbose:
|
||||
self.logger.debug("Using fallback streamed text for report")
|
||||
|
||||
# Try to extract citations from grounding metadata
|
||||
if hasattr(interaction, 'outputs') and interaction.outputs:
|
||||
for output in interaction.outputs:
|
||||
if hasattr(output, 'grounding_metadata'):
|
||||
metadata = output.grounding_metadata
|
||||
if hasattr(metadata, 'grounding_chunks'):
|
||||
for chunk in metadata.grounding_chunks:
|
||||
if hasattr(chunk, 'web') and chunk.web:
|
||||
citations.append(Citation(
|
||||
title=getattr(chunk.web, 'title', ''),
|
||||
url=getattr(chunk.web, 'uri', ''),
|
||||
))
|
||||
|
||||
# Log warning if report is empty
|
||||
if not report:
|
||||
self.logger.warning(
|
||||
"Gemini response parsing returned empty report. "
|
||||
f"Interaction ID: {getattr(interaction, 'id', 'unknown')}, "
|
||||
f"Status: {getattr(interaction, 'status', 'unknown')}"
|
||||
)
|
||||
|
||||
return DeepResearchResponse(
|
||||
report=report,
|
||||
@@ -878,10 +941,32 @@ You are a professional research analyst. When conducting research:
|
||||
print("\n\n" + "=" * 60)
|
||||
print("✅ Research Complete")
|
||||
print("=" * 60 + "\n")
|
||||
# Get final interaction for full response
|
||||
|
||||
# Poll until interaction status is actually 'completed'
|
||||
# (workaround for timing issue where GET returns stale status)
|
||||
if interaction_id:
|
||||
final_interaction = self.gemini_client.interactions.get(interaction_id)
|
||||
return self._parse_gemini_response(final_interaction)
|
||||
max_poll_attempts = 30 # 30 seconds max
|
||||
for attempt in range(max_poll_attempts):
|
||||
final_interaction = self.gemini_client.interactions.get(interaction_id)
|
||||
if final_interaction.status == "completed":
|
||||
return self._parse_gemini_response(
|
||||
final_interaction,
|
||||
fallback_text=final_text,
|
||||
fallback_reasoning=reasoning_steps
|
||||
)
|
||||
elif final_interaction.status in ["failed", "cancelled"]:
|
||||
raise RuntimeError(f"Research {final_interaction.status}")
|
||||
time.sleep(1)
|
||||
|
||||
# If still not completed, use fallback
|
||||
if self.verbose:
|
||||
self.logger.warning("Interaction not completed after polling, using streamed content")
|
||||
return DeepResearchResponse(
|
||||
report=final_text,
|
||||
reasoning_steps=reasoning_steps,
|
||||
provider="gemini",
|
||||
interaction_id=interaction_id
|
||||
)
|
||||
|
||||
elif chunk.event_type == "error":
|
||||
error_msg = getattr(chunk, 'error', 'Unknown streaming error')
|
||||
@@ -949,7 +1034,11 @@ You are a professional research analyst. When conducting research:
|
||||
if self.verbose:
|
||||
print("\n\n✅ Research Complete (resumed)")
|
||||
final_interaction = self.gemini_client.interactions.get(interaction_id)
|
||||
return self._parse_gemini_response(final_interaction)
|
||||
return self._parse_gemini_response(
|
||||
final_interaction,
|
||||
fallback_text=accumulated_text,
|
||||
fallback_reasoning=reasoning_steps
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
retry_count += 1
|
||||
@@ -963,7 +1052,11 @@ You are a professional research analyst. When conducting research:
|
||||
while True:
|
||||
interaction = self.gemini_client.interactions.get(interaction_id)
|
||||
if interaction.status == "completed":
|
||||
return self._parse_gemini_response(interaction)
|
||||
return self._parse_gemini_response(
|
||||
interaction,
|
||||
fallback_text=accumulated_text,
|
||||
fallback_reasoning=reasoning_steps
|
||||
)
|
||||
elif interaction.status in ["failed", "cancelled"]:
|
||||
raise RuntimeError(f"Research {interaction.status}")
|
||||
time.sleep(self.poll_interval)
|
||||
|
||||
@@ -183,6 +183,16 @@ class ImageAgent(Agent):
|
||||
async def agenerate_image(self, prompt: str, **kwargs) -> Dict[str, Any]:
|
||||
"""Async wrapper for generate_image."""
|
||||
return self.generate_image(prompt, **kwargs)
|
||||
|
||||
# Aliases for consistency with other agents
|
||||
def generate(self, prompt: str, **kwargs) -> Dict[str, Any]:
|
||||
"""Alias for generate_image() - for consistency with VideoAgent/AudioAgent."""
|
||||
return self.generate_image(prompt, **kwargs)
|
||||
|
||||
async def agenerate(self, prompt: str, **kwargs) -> Dict[str, Any]:
|
||||
"""Async alias for generate_image()."""
|
||||
return self.generate_image(prompt, **kwargs)
|
||||
|
||||
def chat(self, prompt: str, **kwargs) -> Dict[str, Any]:
|
||||
"""Generate an image from the prompt."""
|
||||
try:
|
||||
@@ -216,3 +226,153 @@ class ImageAgent(Agent):
|
||||
if self.verbose:
|
||||
self.console.print(f"[red]{error_msg}[/red]")
|
||||
return {"error": str(e)}
|
||||
|
||||
def edit(
|
||||
self,
|
||||
image: str,
|
||||
prompt: str,
|
||||
mask: Optional[str] = None,
|
||||
n: int = 1,
|
||||
size: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Edit an existing image with a prompt.
|
||||
|
||||
Args:
|
||||
image: Path or URL to the image to edit
|
||||
prompt: Description of the desired edits
|
||||
mask: Optional mask image (transparent areas will be edited)
|
||||
n: Number of images to generate
|
||||
size: Output image size
|
||||
**kwargs: Additional provider-specific parameters
|
||||
|
||||
Returns:
|
||||
ImageResponse with edited image(s)
|
||||
|
||||
Example:
|
||||
```python
|
||||
agent = ImageAgent(llm="openai/dall-e-2")
|
||||
result = agent.edit("photo.png", "Add a sunset in the background")
|
||||
```
|
||||
"""
|
||||
try:
|
||||
import litellm
|
||||
litellm.telemetry = False
|
||||
|
||||
model_name = self.llm or self.model or "dall-e-2"
|
||||
|
||||
config = {
|
||||
"model": model_name,
|
||||
"image": image,
|
||||
"prompt": prompt,
|
||||
"n": n,
|
||||
}
|
||||
if mask:
|
||||
config["mask"] = mask
|
||||
if size:
|
||||
config["size"] = size
|
||||
if self.image_config.api_key:
|
||||
config["api_key"] = self.image_config.api_key
|
||||
if self.image_config.api_base:
|
||||
config["api_base"] = self.image_config.api_base
|
||||
|
||||
config.update(kwargs)
|
||||
|
||||
if self.verbose:
|
||||
self.console.print(f"[cyan]Editing image with {model_name}...[/cyan]")
|
||||
|
||||
response = litellm.image_edit(**config)
|
||||
|
||||
if self.verbose:
|
||||
self.console.print(f"[green]✓ Image edited successfully[/green]")
|
||||
|
||||
return response
|
||||
except Exception as e:
|
||||
error_msg = f"Error editing image: {str(e)}"
|
||||
if self.verbose:
|
||||
self.console.print(f"[red]{error_msg}[/red]")
|
||||
raise
|
||||
|
||||
async def aedit(
|
||||
self,
|
||||
image: str,
|
||||
prompt: str,
|
||||
mask: Optional[str] = None,
|
||||
n: int = 1,
|
||||
size: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> Dict[str, Any]:
|
||||
"""Async version of edit()."""
|
||||
return self.edit(image, prompt, mask, n, size, **kwargs)
|
||||
|
||||
def variation(
|
||||
self,
|
||||
image: str,
|
||||
n: int = 1,
|
||||
size: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate variations of an existing image.
|
||||
|
||||
Args:
|
||||
image: Path or URL to the source image
|
||||
n: Number of variations to generate
|
||||
size: Output image size
|
||||
**kwargs: Additional provider-specific parameters
|
||||
|
||||
Returns:
|
||||
ImageResponse with image variations
|
||||
|
||||
Example:
|
||||
```python
|
||||
agent = ImageAgent(llm="openai/dall-e-2")
|
||||
result = agent.variation("original.png", n=3)
|
||||
```
|
||||
"""
|
||||
try:
|
||||
import litellm
|
||||
litellm.telemetry = False
|
||||
|
||||
model_name = self.llm or self.model or "dall-e-2"
|
||||
|
||||
config = {
|
||||
"model": model_name,
|
||||
"image": image,
|
||||
"n": n,
|
||||
}
|
||||
if size:
|
||||
config["size"] = size
|
||||
if self.image_config.api_key:
|
||||
config["api_key"] = self.image_config.api_key
|
||||
if self.image_config.api_base:
|
||||
config["api_base"] = self.image_config.api_base
|
||||
|
||||
config.update(kwargs)
|
||||
|
||||
if self.verbose:
|
||||
self.console.print(f"[cyan]Generating variations with {model_name}...[/cyan]")
|
||||
|
||||
response = litellm.image_variation(**config)
|
||||
|
||||
if self.verbose:
|
||||
self.console.print(f"[green]✓ Variations generated successfully[/green]")
|
||||
|
||||
return response
|
||||
except Exception as e:
|
||||
error_msg = f"Error generating variations: {str(e)}"
|
||||
if self.verbose:
|
||||
self.console.print(f"[red]{error_msg}[/red]")
|
||||
raise
|
||||
|
||||
async def avariation(
|
||||
self,
|
||||
image: str,
|
||||
n: int = 1,
|
||||
size: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> Dict[str, Any]:
|
||||
"""Async version of variation()."""
|
||||
return self.variation(image, n, size, **kwargs)
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
"""
|
||||
OCRAgent - A specialized agent class for OCR (Optical Character Recognition).
|
||||
Extracts text from documents and images using AI models.
|
||||
|
||||
Follows the Agent() class patterns:
|
||||
- Precedence Ladder: Instance > Config > Array > Dict > String > Bool > Default
|
||||
- Lazy imports for LiteLLM (zero overhead until first use)
|
||||
- Async-safe with both sync and async methods
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
import warnings
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Any, Dict, Union, List
|
||||
from pathlib import Path
|
||||
|
||||
warnings.filterwarnings("ignore", "Valid config keys have changed in V2", UserWarning)
|
||||
|
||||
|
||||
@dataclass
|
||||
class OCRConfig:
|
||||
"""Configuration for OCR settings."""
|
||||
include_image_base64: bool = False
|
||||
pages: Optional[List[int]] = None
|
||||
image_limit: Optional[int] = None
|
||||
timeout: int = 600
|
||||
api_base: Optional[str] = None
|
||||
api_key: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for LiteLLM calls."""
|
||||
return {
|
||||
"include_image_base64": self.include_image_base64,
|
||||
"pages": self.pages,
|
||||
"image_limit": self.image_limit,
|
||||
"timeout": self.timeout,
|
||||
"api_base": self.api_base,
|
||||
"api_key": self.api_key,
|
||||
}
|
||||
|
||||
|
||||
class OCRAgent:
|
||||
"""
|
||||
A specialized agent for OCR (Optical Character Recognition).
|
||||
|
||||
Extracts text from documents (PDFs) and images using AI models.
|
||||
|
||||
Supported Providers:
|
||||
- Mistral: `mistral/mistral-ocr-latest`
|
||||
|
||||
Example:
|
||||
```python
|
||||
from praisonaiagents import OCRAgent
|
||||
|
||||
agent = OCRAgent(llm="mistral/mistral-ocr-latest")
|
||||
|
||||
# Extract from PDF URL
|
||||
result = agent.extract("https://example.com/document.pdf")
|
||||
print(result.text)
|
||||
|
||||
# Extract from image URL
|
||||
result = agent.extract("https://example.com/image.png")
|
||||
for page in result.pages:
|
||||
print(page.markdown)
|
||||
```
|
||||
"""
|
||||
|
||||
DEFAULT_MODEL = "mistral/mistral-ocr-latest"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: Optional[str] = None,
|
||||
instructions: Optional[str] = None,
|
||||
llm: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
ocr: Optional[Union[bool, Dict, "OCRConfig"]] = None,
|
||||
verbose: Union[bool, int] = True,
|
||||
):
|
||||
"""Initialize OCRAgent.
|
||||
|
||||
Args:
|
||||
name: Agent name for identification
|
||||
instructions: Optional instructions
|
||||
llm: Model name (e.g., "mistral/mistral-ocr-latest")
|
||||
model: Alias for llm= parameter
|
||||
base_url: Custom API endpoint URL
|
||||
api_key: API key for the provider
|
||||
ocr: OCR configuration
|
||||
verbose: Verbosity level for output
|
||||
"""
|
||||
if llm is None and model is not None:
|
||||
llm = model
|
||||
|
||||
self.name = name or "OCRAgent"
|
||||
self.instructions = instructions
|
||||
self.llm = llm or os.getenv('PRAISONAI_OCR_MODEL', self.DEFAULT_MODEL)
|
||||
self.base_url = base_url
|
||||
self.api_key = api_key
|
||||
self._ocr_config = self._resolve_ocr_config(ocr)
|
||||
self.verbose = verbose
|
||||
|
||||
self._litellm = None
|
||||
self._console = None
|
||||
|
||||
self._configure_logging(verbose)
|
||||
|
||||
def _resolve_ocr_config(self, ocr: Optional[Union[bool, Dict, OCRConfig]]) -> OCRConfig:
|
||||
"""Resolve ocr parameter using Precedence Ladder."""
|
||||
if ocr is None or ocr is True or ocr is False:
|
||||
return OCRConfig()
|
||||
elif isinstance(ocr, OCRConfig):
|
||||
return ocr
|
||||
elif isinstance(ocr, dict):
|
||||
return OCRConfig(**ocr)
|
||||
return OCRConfig()
|
||||
|
||||
@property
|
||||
def console(self):
|
||||
"""Lazily initialize Rich Console."""
|
||||
if self._console is None:
|
||||
from rich.console import Console
|
||||
self._console = Console()
|
||||
return self._console
|
||||
|
||||
@property
|
||||
def litellm(self):
|
||||
"""Lazy load litellm module when needed."""
|
||||
if self._litellm is None:
|
||||
try:
|
||||
import litellm
|
||||
litellm.telemetry = False
|
||||
litellm.success_callback = []
|
||||
self._litellm = litellm
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"litellm is required for OCR. "
|
||||
"Please install with: pip install litellm"
|
||||
)
|
||||
return self._litellm
|
||||
|
||||
def _configure_logging(self, verbose: Union[bool, int]) -> None:
|
||||
"""Configure logging levels."""
|
||||
if isinstance(verbose, int) and verbose >= 10:
|
||||
logging.getLogger("litellm").setLevel(logging.DEBUG)
|
||||
else:
|
||||
logging.getLogger("litellm").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
|
||||
def _get_model_params(self, model: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Build parameters for LiteLLM calls."""
|
||||
params = {"model": model or self.llm}
|
||||
if self.api_key:
|
||||
params["api_key"] = self.api_key
|
||||
if self.base_url:
|
||||
params["api_base"] = self.base_url
|
||||
return params
|
||||
|
||||
def _build_document(self, source: str) -> Dict[str, str]:
|
||||
"""Build document dict from source URL or path."""
|
||||
# Determine if it's an image or document
|
||||
source_lower = source.lower()
|
||||
if any(ext in source_lower for ext in ['.png', '.jpg', '.jpeg', '.gif', '.webp']):
|
||||
return {"type": "image_url", "image_url": source}
|
||||
else:
|
||||
return {"type": "document_url", "document_url": source}
|
||||
|
||||
def extract(
|
||||
self,
|
||||
source: str,
|
||||
include_image_base64: Optional[bool] = None,
|
||||
pages: Optional[List[int]] = None,
|
||||
image_limit: Optional[int] = None,
|
||||
model: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> Any:
|
||||
"""
|
||||
Extract text from a document or image.
|
||||
|
||||
Args:
|
||||
source: URL or path to document/image
|
||||
include_image_base64: Include base64 images in response
|
||||
pages: Specific pages to extract (for PDFs)
|
||||
image_limit: Maximum images per page
|
||||
model: Override model for this call
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Returns:
|
||||
OCRResponse with pages, markdown content, and metadata
|
||||
|
||||
Example:
|
||||
```python
|
||||
agent = OCRAgent(llm="mistral/mistral-ocr-latest")
|
||||
result = agent.extract("https://arxiv.org/pdf/2201.04234")
|
||||
for page in result.pages:
|
||||
print(f"Page {page.index}: {page.markdown}")
|
||||
```
|
||||
"""
|
||||
model = model or self.llm
|
||||
|
||||
params = self._get_model_params(model)
|
||||
params["document"] = self._build_document(source)
|
||||
|
||||
# Apply config values
|
||||
if include_image_base64 is not None:
|
||||
params["include_image_base64"] = include_image_base64
|
||||
elif self._ocr_config.include_image_base64:
|
||||
params["include_image_base64"] = self._ocr_config.include_image_base64
|
||||
|
||||
if pages is not None:
|
||||
params["pages"] = pages
|
||||
elif self._ocr_config.pages:
|
||||
params["pages"] = self._ocr_config.pages
|
||||
|
||||
if image_limit is not None:
|
||||
params["image_limit"] = image_limit
|
||||
elif self._ocr_config.image_limit:
|
||||
params["image_limit"] = self._ocr_config.image_limit
|
||||
|
||||
params.update(kwargs)
|
||||
|
||||
if self.verbose:
|
||||
self.console.print(f"[cyan]Extracting text with {model}...[/cyan]")
|
||||
|
||||
response = self.litellm.ocr(**params)
|
||||
|
||||
if self.verbose:
|
||||
self.console.print(f"[green]✓ OCR complete[/green]")
|
||||
|
||||
return response
|
||||
|
||||
async def aextract(
|
||||
self,
|
||||
source: str,
|
||||
include_image_base64: Optional[bool] = None,
|
||||
pages: Optional[List[int]] = None,
|
||||
image_limit: Optional[int] = None,
|
||||
model: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> Any:
|
||||
"""Async version of extract()."""
|
||||
model = model or self.llm
|
||||
|
||||
params = self._get_model_params(model)
|
||||
params["document"] = self._build_document(source)
|
||||
|
||||
if include_image_base64 is not None:
|
||||
params["include_image_base64"] = include_image_base64
|
||||
elif self._ocr_config.include_image_base64:
|
||||
params["include_image_base64"] = self._ocr_config.include_image_base64
|
||||
|
||||
if pages is not None:
|
||||
params["pages"] = pages
|
||||
elif self._ocr_config.pages:
|
||||
params["pages"] = self._ocr_config.pages
|
||||
|
||||
if image_limit is not None:
|
||||
params["image_limit"] = image_limit
|
||||
elif self._ocr_config.image_limit:
|
||||
params["image_limit"] = self._ocr_config.image_limit
|
||||
|
||||
params.update(kwargs)
|
||||
|
||||
return await self.litellm.aocr(**params)
|
||||
|
||||
# Convenience methods
|
||||
def read(self, source: str, **kwargs) -> str:
|
||||
"""
|
||||
Quick OCR - extract and return markdown text.
|
||||
|
||||
Args:
|
||||
source: URL or path to document/image
|
||||
|
||||
Returns:
|
||||
Extracted text as markdown string
|
||||
"""
|
||||
response = self.extract(source, **kwargs)
|
||||
|
||||
# Combine all pages into markdown
|
||||
if hasattr(response, 'pages'):
|
||||
return "\n\n".join(
|
||||
page.markdown for page in response.pages
|
||||
if hasattr(page, 'markdown')
|
||||
)
|
||||
return str(response)
|
||||
|
||||
async def aread(self, source: str, **kwargs) -> str:
|
||||
"""Async version of read()."""
|
||||
response = await self.aextract(source, **kwargs)
|
||||
|
||||
if hasattr(response, 'pages'):
|
||||
return "\n\n".join(
|
||||
page.markdown for page in response.pages
|
||||
if hasattr(page, 'markdown')
|
||||
)
|
||||
return str(response)
|
||||
@@ -142,10 +142,97 @@ class FullAgentProtocol(RunnableAgentProtocol, ToolAwareAgentProtocol, MemoryAwa
|
||||
pass
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ContextEngineerProtocol(Protocol):
|
||||
"""
|
||||
Protocol for Context Engineering agents.
|
||||
|
||||
Defines the interface for agents that perform codebase analysis,
|
||||
PRP (Product Requirements Prompt) generation, and implementation planning.
|
||||
|
||||
This protocol enables:
|
||||
- Lightweight core with protocol definition only
|
||||
- Heavy implementations in wrapper/tools
|
||||
- Easy mocking for tests
|
||||
|
||||
Example:
|
||||
```python
|
||||
class MockContextEngineer:
|
||||
def analyze_codebase(self, path: str) -> Dict[str, Any]:
|
||||
return {"project": path, "patterns": []}
|
||||
|
||||
def generate_prp(self, request: str, analysis: Dict = None) -> str:
|
||||
return f"PRP for: {request}"
|
||||
|
||||
async def aanalyze_codebase(self, path: str) -> Dict[str, Any]:
|
||||
return await self.analyze_codebase(path)
|
||||
```
|
||||
"""
|
||||
|
||||
def analyze_codebase(self, project_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Analyze a codebase and extract patterns, structure, and conventions.
|
||||
|
||||
Args:
|
||||
project_path: Path to the project directory
|
||||
|
||||
Returns:
|
||||
Analysis results including patterns, architecture, conventions
|
||||
"""
|
||||
...
|
||||
|
||||
def generate_prp(
|
||||
self,
|
||||
feature_request: str,
|
||||
context_analysis: Optional[Dict[str, Any]] = None
|
||||
) -> str:
|
||||
"""
|
||||
Generate a Product Requirements Prompt (PRP) for a feature.
|
||||
|
||||
Args:
|
||||
feature_request: Description of the feature to implement
|
||||
context_analysis: Optional codebase analysis to include
|
||||
|
||||
Returns:
|
||||
Comprehensive PRP document as string
|
||||
"""
|
||||
...
|
||||
|
||||
def create_implementation_blueprint(
|
||||
self,
|
||||
feature_request: str,
|
||||
context_analysis: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a step-by-step implementation blueprint.
|
||||
|
||||
Args:
|
||||
feature_request: Description of the feature
|
||||
context_analysis: Optional codebase analysis
|
||||
|
||||
Returns:
|
||||
Blueprint with implementation steps, files to modify, etc.
|
||||
"""
|
||||
...
|
||||
|
||||
async def aanalyze_codebase(self, project_path: str) -> Dict[str, Any]:
|
||||
"""Async version of analyze_codebase."""
|
||||
...
|
||||
|
||||
async def agenerate_prp(
|
||||
self,
|
||||
feature_request: str,
|
||||
context_analysis: Optional[Dict[str, Any]] = None
|
||||
) -> str:
|
||||
"""Async version of generate_prp."""
|
||||
...
|
||||
|
||||
|
||||
__all__ = [
|
||||
'AgentProtocol',
|
||||
'RunnableAgentProtocol',
|
||||
'ToolAwareAgentProtocol',
|
||||
'MemoryAwareAgentProtocol',
|
||||
'FullAgentProtocol',
|
||||
'ContextEngineerProtocol',
|
||||
]
|
||||
@@ -0,0 +1,693 @@
|
||||
"""
|
||||
VideoAgent - A specialized agent class for generating videos using AI models.
|
||||
This class extends the base Agent class to provide specific functionality for video generation,
|
||||
including support for different video models, sizes, durations, and polling workflow.
|
||||
|
||||
Follows the Agent() class patterns:
|
||||
- Precedence Ladder: Instance > Config > Array > Dict > String > Bool > Default
|
||||
- Lazy imports for LiteLLM (zero overhead until first use)
|
||||
- Async-safe with both sync and async methods
|
||||
- Multi-agent safe (no shared state)
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
import asyncio
|
||||
import warnings
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Any, Dict, Union, List, Generator
|
||||
from rich.console import Console
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn
|
||||
|
||||
# Filter out Pydantic warning about fields
|
||||
warnings.filterwarnings("ignore", "Valid config keys have changed in V2", UserWarning)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# VideoConfig - Configuration dataclass following feature_configs.py patterns
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class VideoConfig:
|
||||
"""
|
||||
Configuration for video generation settings.
|
||||
|
||||
Follows the Precedence Ladder pattern:
|
||||
- Instance > Config > Array > Dict > String > Bool > Default
|
||||
|
||||
Usage:
|
||||
# Simple string (model name)
|
||||
VideoAgent(llm="openai/sora-2")
|
||||
|
||||
# Bool (enable/disable - uses default model)
|
||||
VideoAgent(video=True) # Uses default model
|
||||
|
||||
# Dict
|
||||
VideoAgent(video={"seconds": "8", "size": "1280x720"})
|
||||
|
||||
# Config instance
|
||||
VideoAgent(video=VideoConfig(seconds="16", size="720x1280"))
|
||||
"""
|
||||
# Video duration in seconds
|
||||
seconds: Optional[str] = "8"
|
||||
|
||||
# Video dimensions (e.g., "720x1280", "1280x720")
|
||||
size: Optional[str] = None
|
||||
|
||||
# Reference image for image-to-video or remix operations
|
||||
input_reference: Optional[Any] = None
|
||||
|
||||
# Timeout for generation request (video generation can take minutes)
|
||||
timeout: int = 600
|
||||
|
||||
# Optional API configuration (usually inherited from llm= param)
|
||||
api_base: Optional[str] = None
|
||||
api_key: Optional[str] = None
|
||||
api_version: Optional[str] = None
|
||||
|
||||
# Polling configuration for wait_for_completion
|
||||
poll_interval: int = 10 # seconds between status checks
|
||||
max_wait_time: int = 600 # maximum wait time in seconds
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for LiteLLM calls."""
|
||||
return {
|
||||
"seconds": self.seconds,
|
||||
"size": self.size,
|
||||
"input_reference": self.input_reference,
|
||||
"timeout": self.timeout,
|
||||
"api_base": self.api_base,
|
||||
"api_key": self.api_key,
|
||||
"api_version": self.api_version,
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# VideoAgent Class - Agent-centric video generation
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class VideoAgent:
|
||||
"""
|
||||
A specialized agent for generating videos using AI models.
|
||||
|
||||
This agent provides a simple, agent-centric interface for video generation
|
||||
with support for multiple providers (OpenAI Sora, Azure, Gemini Veo, Vertex AI, RunwayML).
|
||||
|
||||
Supported Providers:
|
||||
- OpenAI: openai/sora-2, openai/sora-2-pro
|
||||
- Azure: azure/sora-2, azure/sora-2-pro
|
||||
- Gemini: gemini/veo-3.0-generate-preview, gemini/veo-3.1-*
|
||||
- Vertex AI: vertex_ai/veo-3.0-*, vertex_ai/veo-3.1-*
|
||||
- RunwayML: runwayml/gen4_turbo (requires input_reference)
|
||||
|
||||
Example:
|
||||
```python
|
||||
from praisonaiagents import VideoAgent
|
||||
|
||||
# Simple usage
|
||||
agent = VideoAgent(llm="openai/sora-2")
|
||||
video = agent.generate(prompt="A cat playing with yarn")
|
||||
|
||||
# Wait for completion
|
||||
video = agent.start(
|
||||
prompt="A serene lake at sunset",
|
||||
wait=True, # Wait for completion
|
||||
output="video.mp4" # Save to file
|
||||
)
|
||||
|
||||
# With config
|
||||
agent = VideoAgent(
|
||||
llm="gemini/veo-3.0-generate-preview",
|
||||
video=VideoConfig(seconds="8", size="1280x720")
|
||||
)
|
||||
```
|
||||
"""
|
||||
|
||||
# Default model when none specified
|
||||
DEFAULT_MODEL = "openai/sora-2"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
# Core identity (optional for VideoAgent)
|
||||
name: Optional[str] = None,
|
||||
instructions: Optional[str] = None,
|
||||
# LLM configuration - primary way to specify model
|
||||
llm: Optional[Union[str, Any]] = None,
|
||||
model: Optional[Union[str, Any]] = None, # Alias for llm=
|
||||
# Connection/auth (kept separate per Agent pattern)
|
||||
base_url: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
api_version: Optional[str] = None,
|
||||
# Video-specific configuration
|
||||
video: Optional[Union[bool, Dict, "VideoConfig"]] = None,
|
||||
# Output configuration
|
||||
verbose: Union[bool, int] = True,
|
||||
output: Optional[str] = None, # Output preset
|
||||
):
|
||||
"""Initialize VideoAgent.
|
||||
|
||||
Args:
|
||||
name: Agent name for identification
|
||||
instructions: Optional instructions (unused for video generation)
|
||||
llm: Model name string (e.g., "openai/sora-2", "gemini/veo-3.0-generate-preview")
|
||||
model: Alias for llm= parameter
|
||||
base_url: Custom API endpoint URL
|
||||
api_key: API key for the provider
|
||||
api_version: API version (for Azure)
|
||||
video: Video generation configuration. Accepts:
|
||||
- bool: True enables with defaults
|
||||
- dict: {"seconds": "8", "size": "1280x720"}
|
||||
- VideoConfig: Full configuration object
|
||||
verbose: Verbosity level for output
|
||||
output: Output preset ("silent", "verbose", etc.)
|
||||
"""
|
||||
# Handle model= alias for llm=
|
||||
if llm is None and model is not None:
|
||||
llm = model
|
||||
|
||||
# Store core identity
|
||||
self.name = name or "VideoAgent"
|
||||
self.instructions = instructions
|
||||
|
||||
# Store the model name
|
||||
self.llm = llm or os.getenv('PRAISONAI_VIDEO_MODEL', self.DEFAULT_MODEL)
|
||||
|
||||
# Store connection parameters (kept separate per Agent pattern)
|
||||
self.base_url = base_url
|
||||
self.api_key = api_key
|
||||
self.api_version = api_version
|
||||
|
||||
# Resolve video configuration using Precedence Ladder
|
||||
self._video_config = self._resolve_video_config(video)
|
||||
|
||||
# Output configuration
|
||||
self.verbose = verbose
|
||||
self._output_preset = output
|
||||
|
||||
# Lazy load LiteLLM video module (zero overhead until first use)
|
||||
self._litellm_video = None
|
||||
self._litellm = None
|
||||
|
||||
# Console for Rich output
|
||||
self._console = None
|
||||
|
||||
# Configure logging
|
||||
self._configure_logging(verbose)
|
||||
|
||||
def _resolve_video_config(self, video: Optional[Union[bool, Dict, VideoConfig]]) -> VideoConfig:
|
||||
"""
|
||||
Resolve video parameter using Precedence Ladder.
|
||||
|
||||
Precedence: Instance > Config > Array > Dict > String > Bool > Default
|
||||
"""
|
||||
if video is None or video is True:
|
||||
return VideoConfig()
|
||||
elif video is False:
|
||||
return VideoConfig()
|
||||
elif isinstance(video, VideoConfig):
|
||||
return video
|
||||
elif isinstance(video, dict):
|
||||
return VideoConfig(**video)
|
||||
else:
|
||||
return VideoConfig()
|
||||
|
||||
@property
|
||||
def console(self) -> Console:
|
||||
"""Lazily initialize Rich Console."""
|
||||
if self._console is None:
|
||||
self._console = Console()
|
||||
return self._console
|
||||
|
||||
@property
|
||||
def video_module(self):
|
||||
"""Lazy load litellm.videos module when needed."""
|
||||
if self._litellm_video is None:
|
||||
try:
|
||||
from litellm.videos import (
|
||||
video_generation,
|
||||
avideo_generation,
|
||||
video_status,
|
||||
avideo_status,
|
||||
video_content,
|
||||
avideo_content,
|
||||
video_list,
|
||||
avideo_list,
|
||||
video_remix,
|
||||
avideo_remix,
|
||||
)
|
||||
import litellm
|
||||
|
||||
# Disable LiteLLM telemetry and debug logging
|
||||
litellm.telemetry = False
|
||||
litellm.success_callback = []
|
||||
|
||||
self._litellm_video = {
|
||||
"video_generation": video_generation,
|
||||
"avideo_generation": avideo_generation,
|
||||
"video_status": video_status,
|
||||
"avideo_status": avideo_status,
|
||||
"video_content": video_content,
|
||||
"avideo_content": avideo_content,
|
||||
"video_list": video_list,
|
||||
"avideo_list": avideo_list,
|
||||
"video_remix": video_remix,
|
||||
"avideo_remix": avideo_remix,
|
||||
}
|
||||
self._litellm = litellm
|
||||
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"litellm is required for video generation. "
|
||||
"Please install with: pip install litellm"
|
||||
)
|
||||
return self._litellm_video
|
||||
|
||||
def _configure_logging(self, verbose: Union[bool, int]) -> None:
|
||||
"""Configure logging levels based on verbose setting."""
|
||||
if isinstance(verbose, int) and verbose >= 10:
|
||||
# Debug mode
|
||||
logging.getLogger("litellm").setLevel(logging.DEBUG)
|
||||
else:
|
||||
# Suppress debug logging
|
||||
logging.getLogger("litellm").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
|
||||
def _get_model_params(self) -> Dict[str, Any]:
|
||||
"""Build parameters for LiteLLM video calls."""
|
||||
params = {"model": self.llm}
|
||||
if self.api_key:
|
||||
params["api_key"] = self.api_key
|
||||
if self.base_url:
|
||||
params["api_base"] = self.base_url
|
||||
if self.api_version:
|
||||
params["api_version"] = self.api_version
|
||||
return params
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Core Video Operations (5 operations, sync + async)
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
seconds: Optional[str] = None,
|
||||
size: Optional[str] = None,
|
||||
input_reference: Optional[Any] = None,
|
||||
**kwargs
|
||||
) -> Any:
|
||||
"""
|
||||
Generate a video from a text prompt.
|
||||
|
||||
Args:
|
||||
prompt: Text description of the desired video
|
||||
seconds: Video duration (e.g., "8", "16"). Defaults to config value.
|
||||
size: Video dimensions (e.g., "720x1280"). Defaults to config value.
|
||||
input_reference: Reference image for image-to-video generation
|
||||
**kwargs: Additional provider-specific parameters
|
||||
|
||||
Returns:
|
||||
VideoObject with id, status, and metadata
|
||||
|
||||
Example:
|
||||
```python
|
||||
video = agent.generate(
|
||||
prompt="A cat playing with yarn",
|
||||
seconds="8",
|
||||
size="1280x720"
|
||||
)
|
||||
print(f"Video ID: {video.id}")
|
||||
print(f"Status: {video.status}")
|
||||
```
|
||||
"""
|
||||
# Merge config with kwargs
|
||||
config = self._video_config.to_dict()
|
||||
if seconds:
|
||||
config["seconds"] = seconds
|
||||
if size:
|
||||
config["size"] = size
|
||||
if input_reference:
|
||||
config["input_reference"] = input_reference
|
||||
config.update(kwargs)
|
||||
|
||||
# Remove None values
|
||||
config = {k: v for k, v in config.items() if v is not None}
|
||||
|
||||
# Get model parameters
|
||||
params = self._get_model_params()
|
||||
params["prompt"] = prompt
|
||||
params.update(config)
|
||||
|
||||
# Remove internal config keys
|
||||
params.pop("poll_interval", None)
|
||||
params.pop("max_wait_time", None)
|
||||
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
transient=True
|
||||
) as progress:
|
||||
try:
|
||||
task = progress.add_task(f"[cyan]Generating video with {self.llm}...", total=None)
|
||||
response = self.video_module["video_generation"](**params)
|
||||
progress.update(task, completed=True)
|
||||
return response
|
||||
except Exception as e:
|
||||
if self.verbose:
|
||||
self.console.print(f"[red]Error generating video: {e}[/red]")
|
||||
raise
|
||||
|
||||
async def agenerate(
|
||||
self,
|
||||
prompt: str,
|
||||
seconds: Optional[str] = None,
|
||||
size: Optional[str] = None,
|
||||
input_reference: Optional[Any] = None,
|
||||
**kwargs
|
||||
) -> Any:
|
||||
"""Async version of generate()."""
|
||||
config = self._video_config.to_dict()
|
||||
if seconds:
|
||||
config["seconds"] = seconds
|
||||
if size:
|
||||
config["size"] = size
|
||||
if input_reference:
|
||||
config["input_reference"] = input_reference
|
||||
config.update(kwargs)
|
||||
config = {k: v for k, v in config.items() if v is not None}
|
||||
|
||||
params = self._get_model_params()
|
||||
params["prompt"] = prompt
|
||||
params.update(config)
|
||||
params.pop("poll_interval", None)
|
||||
params.pop("max_wait_time", None)
|
||||
|
||||
return await self.video_module["avideo_generation"](**params)
|
||||
|
||||
def status(self, video_id: str, **kwargs) -> Any:
|
||||
"""
|
||||
Check the status of a video generation.
|
||||
|
||||
Args:
|
||||
video_id: The video ID returned from generate()
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Returns:
|
||||
VideoObject with updated status
|
||||
"""
|
||||
return self.video_module["video_status"](video_id=video_id, **kwargs)
|
||||
|
||||
async def astatus(self, video_id: str, **kwargs) -> Any:
|
||||
"""Async version of status()."""
|
||||
return await self.video_module["avideo_status"](video_id=video_id, **kwargs)
|
||||
|
||||
def content(self, video_id: str, **kwargs) -> bytes:
|
||||
"""
|
||||
Download the video content.
|
||||
|
||||
Args:
|
||||
video_id: The video ID to download
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Returns:
|
||||
Raw video bytes (MP4 format)
|
||||
"""
|
||||
return self.video_module["video_content"](video_id=video_id, **kwargs)
|
||||
|
||||
async def acontent(self, video_id: str, **kwargs) -> bytes:
|
||||
"""Async version of content()."""
|
||||
return await self.video_module["avideo_content"](video_id=video_id, **kwargs)
|
||||
|
||||
def list(self, **kwargs) -> List[Any]:
|
||||
"""
|
||||
List all videos for the current account.
|
||||
|
||||
Note: Requires custom_llm_provider for some providers.
|
||||
|
||||
Args:
|
||||
**kwargs: Additional parameters (e.g., custom_llm_provider="openai")
|
||||
|
||||
Returns:
|
||||
List of VideoObject
|
||||
"""
|
||||
return self.video_module["video_list"](**kwargs)
|
||||
|
||||
async def alist(self, **kwargs) -> List[Any]:
|
||||
"""Async version of list()."""
|
||||
return await self.video_module["avideo_list"](**kwargs)
|
||||
|
||||
def remix(self, video_id: str, prompt: str, **kwargs) -> Any:
|
||||
"""
|
||||
Remix/edit an existing video.
|
||||
|
||||
Args:
|
||||
video_id: The ID of the completed video to remix
|
||||
prompt: New prompt describing the desired changes
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Returns:
|
||||
New VideoObject for the remixed video
|
||||
"""
|
||||
return self.video_module["video_remix"](
|
||||
video_id=video_id,
|
||||
prompt=prompt,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
async def aremix(self, video_id: str, prompt: str, **kwargs) -> Any:
|
||||
"""Async version of remix()."""
|
||||
return await self.video_module["avideo_remix"](
|
||||
video_id=video_id,
|
||||
prompt=prompt,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Convenience Methods (following Agent.start()/run() patterns)
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def wait_for_completion(
|
||||
self,
|
||||
video_id: str,
|
||||
poll_interval: Optional[int] = None,
|
||||
max_wait_time: Optional[int] = None,
|
||||
**kwargs
|
||||
) -> Any:
|
||||
"""
|
||||
Wait for video generation to complete.
|
||||
|
||||
Args:
|
||||
video_id: The video ID to poll
|
||||
poll_interval: Seconds between status checks (default: 10)
|
||||
max_wait_time: Maximum wait time in seconds (default: 600)
|
||||
|
||||
Returns:
|
||||
VideoObject with status "completed" or "failed"
|
||||
|
||||
Raises:
|
||||
TimeoutError: If max_wait_time is exceeded
|
||||
"""
|
||||
poll_interval = poll_interval or self._video_config.poll_interval
|
||||
max_wait_time = max_wait_time or self._video_config.max_wait_time
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
transient=True
|
||||
) as progress:
|
||||
task = progress.add_task("[cyan]Waiting for video...", total=None)
|
||||
|
||||
while time.time() - start_time < max_wait_time:
|
||||
status_response = self.status(video_id, **kwargs)
|
||||
|
||||
if status_response.status == "completed":
|
||||
progress.update(task, completed=True)
|
||||
if self.verbose:
|
||||
self.console.print("[green]✓ Video generation completed![/green]")
|
||||
return status_response
|
||||
elif status_response.status == "failed":
|
||||
progress.update(task, completed=True)
|
||||
if self.verbose:
|
||||
self.console.print("[red]✗ Video generation failed[/red]")
|
||||
return status_response
|
||||
|
||||
progress.update(task, description=f"[cyan]Status: {status_response.status}...")
|
||||
time.sleep(poll_interval)
|
||||
|
||||
raise TimeoutError(
|
||||
f"Video generation did not complete within {max_wait_time} seconds"
|
||||
)
|
||||
|
||||
async def await_completion(
|
||||
self,
|
||||
video_id: str,
|
||||
poll_interval: Optional[int] = None,
|
||||
max_wait_time: Optional[int] = None,
|
||||
**kwargs
|
||||
) -> Any:
|
||||
"""Async version of wait_for_completion()."""
|
||||
poll_interval = poll_interval or self._video_config.poll_interval
|
||||
max_wait_time = max_wait_time or self._video_config.max_wait_time
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
while time.time() - start_time < max_wait_time:
|
||||
status_response = await self.astatus(video_id, **kwargs)
|
||||
|
||||
if status_response.status == "completed":
|
||||
return status_response
|
||||
elif status_response.status == "failed":
|
||||
return status_response
|
||||
|
||||
await asyncio.sleep(poll_interval)
|
||||
|
||||
raise TimeoutError(
|
||||
f"Video generation did not complete within {max_wait_time} seconds"
|
||||
)
|
||||
|
||||
def start(
|
||||
self,
|
||||
prompt: str,
|
||||
wait: bool = True,
|
||||
output: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> Union[Any, bytes]:
|
||||
"""
|
||||
Generate video with optional wait and file output.
|
||||
|
||||
Beginner-friendly method that handles the full workflow:
|
||||
1. Generate video
|
||||
2. Wait for completion (if wait=True)
|
||||
3. Download and save to file (if output is specified)
|
||||
|
||||
Args:
|
||||
prompt: Text description of the desired video
|
||||
wait: If True, wait for completion before returning
|
||||
output: Path to save the video file (e.g., "video.mp4")
|
||||
**kwargs: Additional parameters for generate()
|
||||
|
||||
Returns:
|
||||
- If wait=False: VideoObject with initial status
|
||||
- If wait=True and output: bytes (video content)
|
||||
- If wait=True and no output: VideoObject with completed status
|
||||
|
||||
Example:
|
||||
```python
|
||||
# Full workflow with file output
|
||||
video = agent.start(
|
||||
prompt="A serene lake at sunset",
|
||||
wait=True,
|
||||
output="sunset.mp4"
|
||||
)
|
||||
|
||||
# Just generate (don't wait)
|
||||
video = agent.start(
|
||||
prompt="A cat playing",
|
||||
wait=False
|
||||
)
|
||||
print(f"Video ID: {video.id}") # Poll later with status()
|
||||
```
|
||||
"""
|
||||
# Step 1: Generate
|
||||
video = self.generate(prompt, **kwargs)
|
||||
|
||||
if self.verbose:
|
||||
self.console.print(f"[cyan]Video ID: {video.id}[/cyan]")
|
||||
self.console.print(f"[cyan]Initial Status: {video.status}[/cyan]")
|
||||
|
||||
if not wait:
|
||||
return video
|
||||
|
||||
# Step 2: Wait for completion
|
||||
completed_video = self.wait_for_completion(video.id)
|
||||
|
||||
if completed_video.status != "completed":
|
||||
return completed_video
|
||||
|
||||
# Step 3: Download and optionally save
|
||||
if output:
|
||||
video_bytes = self.content(video.id)
|
||||
with open(output, "wb") as f:
|
||||
f.write(video_bytes)
|
||||
if self.verbose:
|
||||
self.console.print(f"[green]✓ Video saved to {output}[/green]")
|
||||
return video_bytes
|
||||
|
||||
return completed_video
|
||||
|
||||
async def astart(
|
||||
self,
|
||||
prompt: str,
|
||||
wait: bool = True,
|
||||
output: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> Union[Any, bytes]:
|
||||
"""Async version of start()."""
|
||||
video = await self.agenerate(prompt, **kwargs)
|
||||
|
||||
if not wait:
|
||||
return video
|
||||
|
||||
completed_video = await self.await_completion(video.id)
|
||||
|
||||
if completed_video.status != "completed":
|
||||
return completed_video
|
||||
|
||||
if output:
|
||||
video_bytes = await self.acontent(video.id)
|
||||
with open(output, "wb") as f:
|
||||
f.write(video_bytes)
|
||||
return video_bytes
|
||||
|
||||
return completed_video
|
||||
|
||||
def run(self, prompt: str, **kwargs) -> Any:
|
||||
"""
|
||||
Generate video silently (production use).
|
||||
|
||||
Unlike start(), this method is always silent regardless of verbose setting.
|
||||
Use for programmatic/scripted usage.
|
||||
|
||||
Args:
|
||||
prompt: Text description of the desired video
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Returns:
|
||||
VideoObject (does not wait for completion)
|
||||
"""
|
||||
return self.generate(prompt, **kwargs)
|
||||
|
||||
async def arun(self, prompt: str, **kwargs) -> Any:
|
||||
"""Async version of run()."""
|
||||
return await self.agenerate(prompt, **kwargs)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Utility Methods
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def download(self, video_id: str, output: str) -> str:
|
||||
"""
|
||||
Download a video to a file.
|
||||
|
||||
Args:
|
||||
video_id: The video ID to download
|
||||
output: Path to save the video file
|
||||
|
||||
Returns:
|
||||
Path to the saved file
|
||||
"""
|
||||
video_bytes = self.content(video_id)
|
||||
with open(output, "wb") as f:
|
||||
f.write(video_bytes)
|
||||
if self.verbose:
|
||||
self.console.print(f"[green]✓ Video saved to {output}[/green]")
|
||||
return output
|
||||
|
||||
async def adownload(self, video_id: str, output: str) -> str:
|
||||
"""Async version of download()."""
|
||||
video_bytes = await self.acontent(video_id)
|
||||
with open(output, "wb") as f:
|
||||
f.write(video_bytes)
|
||||
return output
|
||||
@@ -2,15 +2,12 @@ import os
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, Optional, List, Callable
|
||||
from pydantic import BaseModel
|
||||
from rich.text import Text
|
||||
from rich.panel import Panel
|
||||
from typing import Any, Dict, Optional, List
|
||||
from rich.console import Console
|
||||
from ..main import display_error, TaskOutput, error_logs
|
||||
from ..main import display_error, TaskOutput
|
||||
from ..agent.agent import Agent
|
||||
from ..task.task import Task
|
||||
from ..process.process import Process, LoopItems
|
||||
from ..process.process import Process
|
||||
import asyncio
|
||||
import uuid
|
||||
from enum import Enum
|
||||
@@ -601,9 +598,9 @@ class Agents:
|
||||
# Only import multimodal dependencies if task has images
|
||||
if task.images and task.status == "not started":
|
||||
try:
|
||||
import cv2
|
||||
import base64
|
||||
from moviepy import VideoFileClip
|
||||
import cv2 # noqa: F401 - availability check
|
||||
import base64 # noqa: F401 - availability check
|
||||
from moviepy import VideoFileClip # noqa: F401 - availability check
|
||||
except ImportError as e:
|
||||
display_error(f"Error: Missing required dependencies for image/video processing: {e}")
|
||||
display_error("Please install with: pip install opencv-python moviepy")
|
||||
@@ -618,7 +615,7 @@ class Agents:
|
||||
# Set current agent for token tracking
|
||||
llm = getattr(executor_agent, 'llm', None) or getattr(executor_agent, 'llm_instance', None)
|
||||
if llm and hasattr(llm, 'set_current_agent'):
|
||||
llm.set_current_agent(executor_agent.name)
|
||||
llm.set_current_agent(executor_agent.display_name)
|
||||
|
||||
# Ensure tools are available from both task and agent
|
||||
tools = task.tools or []
|
||||
@@ -651,7 +648,7 @@ Context:
|
||||
task_prompt += "Please provide only the final result of your work. Do not add any conversation or extra explanation."
|
||||
|
||||
if self.verbose >= 2:
|
||||
logger.info(f"Executing task {task_id}: {task.description} using {executor_agent.name}")
|
||||
logger.info(f"Executing task {task_id}: {task.description} using {executor_agent.display_name}")
|
||||
logger.debug(f"Starting execution of task {task_id} with prompt:\n{task_prompt}")
|
||||
|
||||
if task.images:
|
||||
@@ -681,7 +678,7 @@ Context:
|
||||
description=task.description,
|
||||
summary=task.description[:10],
|
||||
raw=agent_output,
|
||||
agent=executor_agent.name,
|
||||
agent=executor_agent.display_name,
|
||||
output_format="RAW"
|
||||
)
|
||||
|
||||
@@ -697,7 +694,7 @@ Context:
|
||||
parsed = json.loads(cleaned)
|
||||
task_output.json_dict = parsed
|
||||
task_output.output_format = "JSON"
|
||||
except:
|
||||
except Exception:
|
||||
logger.warning(f"Warning: Could not parse output of task {task_id} as JSON")
|
||||
logger.debug(f"Output that failed JSON parsing: {agent_output}")
|
||||
|
||||
@@ -708,7 +705,7 @@ Context:
|
||||
pyd_obj = task.output_pydantic(**parsed)
|
||||
task_output.pydantic = pyd_obj
|
||||
task_output.output_format = "Pydantic"
|
||||
except:
|
||||
except Exception:
|
||||
logger.warning(f"Warning: Could not parse output of task {task_id} as Pydantic Model")
|
||||
logger.debug(f"Output that failed Pydantic parsing: {agent_output}")
|
||||
|
||||
@@ -913,9 +910,9 @@ Context:
|
||||
# Only import multimodal dependencies if task has images
|
||||
if task.images and task.status == "not started":
|
||||
try:
|
||||
import cv2
|
||||
import base64
|
||||
from moviepy import VideoFileClip
|
||||
import cv2 # noqa: F401 - availability check
|
||||
import base64 # noqa: F401 - availability check
|
||||
from moviepy import VideoFileClip # noqa: F401 - availability check
|
||||
except ImportError as e:
|
||||
display_error(f"Error: Missing required dependencies for image/video processing: {e}")
|
||||
display_error("Please install with: pip install opencv-python moviepy")
|
||||
@@ -935,7 +932,7 @@ Context:
|
||||
# Set current agent for token tracking
|
||||
llm = getattr(executor_agent, 'llm', None) or getattr(executor_agent, 'llm_instance', None)
|
||||
if llm and hasattr(llm, 'set_current_agent'):
|
||||
llm.set_current_agent(executor_agent.name)
|
||||
llm.set_current_agent(executor_agent.display_name)
|
||||
|
||||
# Substitute variables in task description if provided
|
||||
task_description = task.description
|
||||
@@ -982,7 +979,7 @@ Context:
|
||||
task_prompt += "Please provide only the final result of your work. Do not add any conversation or extra explanation."
|
||||
|
||||
if self.verbose >= 2:
|
||||
logger.info(f"Executing task {task_id}: {task.description} using {executor_agent.name}")
|
||||
logger.info(f"Executing task {task_id}: {task.description} using {executor_agent.display_name}")
|
||||
logger.debug(f"Starting execution of task {task_id} with prompt:\n{task_prompt}")
|
||||
|
||||
if task.images:
|
||||
@@ -1014,7 +1011,7 @@ Context:
|
||||
try:
|
||||
task.store_in_memory(
|
||||
content=agent_output,
|
||||
agent_name=executor_agent.name,
|
||||
agent_name=executor_agent.display_name,
|
||||
task_id=task_id
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -1024,7 +1021,7 @@ Context:
|
||||
description=task.description,
|
||||
summary=task.description[:10],
|
||||
raw=agent_output,
|
||||
agent=executor_agent.name,
|
||||
agent=executor_agent.display_name,
|
||||
output_format="RAW"
|
||||
)
|
||||
|
||||
@@ -1040,7 +1037,7 @@ Context:
|
||||
parsed = json.loads(cleaned)
|
||||
task_output.json_dict = parsed
|
||||
task_output.output_format = "JSON"
|
||||
except:
|
||||
except Exception:
|
||||
logger.warning(f"Warning: Could not parse output of task {task_id} as JSON")
|
||||
logger.debug(f"Output that failed JSON parsing: {agent_output}")
|
||||
|
||||
@@ -1051,7 +1048,7 @@ Context:
|
||||
pyd_obj = task.output_pydantic(**parsed)
|
||||
task_output.pydantic = pyd_obj
|
||||
task_output.output_format = "Pydantic"
|
||||
except:
|
||||
except Exception:
|
||||
logger.warning(f"Warning: Could not parse output of task {task_id} as Pydantic Model")
|
||||
logger.debug(f"Output that failed Pydantic parsing: {agent_output}")
|
||||
|
||||
@@ -1245,12 +1242,11 @@ Context:
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
if show_verbose and is_tty:
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
console = Console()
|
||||
import time as time_module
|
||||
|
||||
# Show workflow overview panel
|
||||
agent_names = " → ".join([a.name for a in self.agents])
|
||||
agent_names = " → ".join([a.display_name for a in self.agents])
|
||||
workflow_info = f"[bold {PRAISON_COLORS['metrics']}]Process:[/] {self.process}\n"
|
||||
workflow_info += f"[bold {PRAISON_COLORS['metrics']}]Agents:[/] {agent_names}"
|
||||
|
||||
@@ -1268,7 +1264,7 @@ Context:
|
||||
|
||||
for idx, (task_id, task) in enumerate(self.tasks.items(), 1):
|
||||
agent = task.agent
|
||||
agent_name = agent.name if agent else "Unknown"
|
||||
agent_name = agent.display_name if agent else "Unknown"
|
||||
agent_model = getattr(agent, 'llm', 'gpt-4o-mini') if agent else "unknown"
|
||||
|
||||
# Show agent task panel with model info
|
||||
@@ -1448,7 +1444,7 @@ Context:
|
||||
"user_id": self.user_id,
|
||||
"run_id": self.run_id,
|
||||
"state": self._state,
|
||||
"agents": [agent.name for agent in self.agents],
|
||||
"agents": [agent.display_name for agent in self.agents],
|
||||
"process": self.process
|
||||
}
|
||||
self.shared_memory.store_short_term(
|
||||
@@ -1660,7 +1656,7 @@ Context:
|
||||
if "query" not in request_data:
|
||||
raise HTTPException(status_code=400, detail="Missing 'query' field in request")
|
||||
query = request_data["query"]
|
||||
except:
|
||||
except Exception:
|
||||
# Fallback to form data or query params
|
||||
form_data = await request.form()
|
||||
if "query" in form_data:
|
||||
@@ -1688,16 +1684,16 @@ Context:
|
||||
|
||||
# Store this agent's result
|
||||
results.append({
|
||||
"agent": agent_instance.name,
|
||||
"agent": agent_instance.display_name,
|
||||
"response": response
|
||||
})
|
||||
|
||||
# Use this response as input to the next agent
|
||||
current_input = response
|
||||
except Exception as e:
|
||||
logging.error(f"Error with agent {agent_instance.name}: {str(e)}", exc_info=True)
|
||||
logging.error(f"Error with agent {agent_instance.display_name}: {str(e)}", exc_info=True)
|
||||
results.append({
|
||||
"agent": agent_instance.name,
|
||||
"agent": agent_instance.display_name,
|
||||
"error": str(e)
|
||||
})
|
||||
# Decide error handling: continue with original input, last good input, or stop?
|
||||
@@ -1720,19 +1716,19 @@ Context:
|
||||
)
|
||||
|
||||
print(f"🚀 Multi-Agent HTTP API available at http://{host}:{port}{path}")
|
||||
agent_names = ", ".join([agent.name for agent in self.agents])
|
||||
agent_names = ", ".join([agent.display_name for agent in self.agents])
|
||||
print(f"📊 Available agents for this endpoint ({len(self.agents)}): {agent_names}")
|
||||
|
||||
# Create per-agent endpoints for individual agent access
|
||||
# This allows n8n and other tools to call specific agents
|
||||
agents_dict = {agent.name.lower().replace(' ', '_'): agent for agent in self.agents}
|
||||
agents_dict = {agent.display_name.lower().replace(' ', '_'): agent for agent in self.agents}
|
||||
|
||||
# Add GET endpoint to list available agents
|
||||
@_agents_shared_apps[port].get(f"{path}/list")
|
||||
async def list_agents():
|
||||
return {
|
||||
"agents": [
|
||||
{"name": agent.name, "id": agent.name.lower().replace(' ', '_')}
|
||||
{"name": agent.display_name, "id": agent.display_name.lower().replace(' ', '_')}
|
||||
for agent in self.agents
|
||||
]
|
||||
}
|
||||
@@ -1749,7 +1745,7 @@ Context:
|
||||
query = request_data.get("query", "")
|
||||
if not query:
|
||||
raise HTTPException(status_code=400, detail="Missing 'query' field")
|
||||
except:
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
||||
|
||||
try:
|
||||
@@ -1760,12 +1756,12 @@ Context:
|
||||
response = await loop.run_in_executor(None, lambda q=query: agent.chat(q))
|
||||
|
||||
return {
|
||||
"agent": agent.name,
|
||||
"agent": agent.display_name,
|
||||
"query": query,
|
||||
"response": response
|
||||
}
|
||||
except Exception as e:
|
||||
logging.error(f"Error with agent {agent.name}: {str(e)}", exc_info=True)
|
||||
logging.error(f"Error with agent {agent.display_name}: {str(e)}", exc_info=True)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": f"Agent error: {str(e)}"}
|
||||
@@ -1888,23 +1884,23 @@ Context:
|
||||
|
||||
for agent_instance in self.agents:
|
||||
try:
|
||||
logging.debug(f"Processing with agent: {agent_instance.name}")
|
||||
logging.debug(f"Processing with agent: {agent_instance.display_name}")
|
||||
if hasattr(agent_instance, 'achat') and asyncio.iscoroutinefunction(agent_instance.achat):
|
||||
response = await agent_instance.achat(current_input, tools=agent_instance.tools, task_name=None, task_description=None, task_id=None)
|
||||
elif hasattr(agent_instance, 'chat'): # Fallback to sync chat if achat not suitable
|
||||
loop = asyncio.get_running_loop()
|
||||
response = await loop.run_in_executor(None, lambda ci=current_input: agent_instance.chat(ci, tools=agent_instance.tools))
|
||||
else:
|
||||
logging.warning(f"Agent {agent_instance.name} has no suitable chat or achat method.")
|
||||
response = f"Error: Agent {agent_instance.name} has no callable chat method."
|
||||
logging.warning(f"Agent {agent_instance.display_name} has no suitable chat or achat method.")
|
||||
response = f"Error: Agent {agent_instance.display_name} has no callable chat method."
|
||||
|
||||
current_input = response if response is not None else "Agent returned no response."
|
||||
final_response = current_input # Keep track of the last valid response
|
||||
logging.debug(f"Agent {agent_instance.name} responded. Current intermediate output: {current_input}")
|
||||
logging.debug(f"Agent {agent_instance.display_name} responded. Current intermediate output: {current_input}")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error during agent {agent_instance.name} execution in MCP workflow: {str(e)}", exc_info=True)
|
||||
current_input = f"Error from agent {agent_instance.name}: {str(e)}"
|
||||
logging.error(f"Error during agent {agent_instance.display_name} execution in MCP workflow: {str(e)}", exc_info=True)
|
||||
current_input = f"Error from agent {agent_instance.display_name}: {str(e)}"
|
||||
final_response = current_input # Update final response to show error
|
||||
# Optionally break or continue based on desired error handling for the workflow
|
||||
# For now, we continue, and the error is passed to the next agent or returned.
|
||||
@@ -1943,7 +1939,7 @@ Context:
|
||||
# Instead of trying to extract tool names, hardcode the known tool name
|
||||
mcp_tool_names = [actual_mcp_tool_name] # Use the determined dynamic tool name
|
||||
print(f"🛠️ Available MCP tools: {', '.join(mcp_tool_names)}")
|
||||
agent_names_in_workflow = ", ".join([a.name for a in self.agents])
|
||||
agent_names_in_workflow = ", ".join([a.display_name for a in self.agents])
|
||||
print(f"🔄 Agents in MCP workflow: {agent_names_in_workflow}")
|
||||
|
||||
def run_praison_mcp_server():
|
||||
@@ -2253,7 +2249,7 @@ Context:
|
||||
console.print("\n[bold blue]🚀 EXECUTION PHASE[/bold blue]\n")
|
||||
|
||||
# Map agent names to agent instances
|
||||
agent_map = {agent.name: agent for agent in self.agents}
|
||||
agent_map = {agent.display_name: agent for agent in self.agents}
|
||||
|
||||
# Store original tasks and create new tasks from plan
|
||||
original_tasks = self.tasks.copy()
|
||||
@@ -2293,7 +2289,7 @@ Context:
|
||||
# Find matching original task for additional config (memory, callbacks, etc.)
|
||||
original_task = None
|
||||
for orig_task in original_tasks.values():
|
||||
if orig_task.agent and orig_task.agent.name == agent.name:
|
||||
if orig_task.agent and orig_task.agent.display_name == agent.display_name:
|
||||
original_task = orig_task
|
||||
break
|
||||
|
||||
@@ -2334,7 +2330,7 @@ Context:
|
||||
console.print(f"[dim]Progress: [{bar}] {progress * 100:.0f}%[/dim]")
|
||||
|
||||
console.print(f"\n[bold]📌 Step {i + 1}/{len(self.tasks)}:[/bold] {task.description[:60]}...")
|
||||
console.print(f"[dim] Agent: {task.agent.name if task.agent else 'Unknown'}[/dim]")
|
||||
console.print(f"[dim] Agent: {task.agent.display_name if task.agent else 'Unknown'}[/dim]")
|
||||
|
||||
# Mark as in progress
|
||||
self._todo_list.start(item.id)
|
||||
|
||||
@@ -211,6 +211,15 @@ class KnowledgeConfig:
|
||||
retrieval_k=5,
|
||||
rerank=True,
|
||||
))
|
||||
|
||||
# With chunker config
|
||||
Agent(knowledge={
|
||||
"sources": ["docs/"],
|
||||
"chunker": {
|
||||
"type": "semantic",
|
||||
"chunk_size": 512
|
||||
}
|
||||
})
|
||||
"""
|
||||
# Knowledge sources (files, directories, URLs)
|
||||
sources: List[str] = field(default_factory=list)
|
||||
@@ -219,11 +228,15 @@ class KnowledgeConfig:
|
||||
embedder: str = "openai"
|
||||
embedder_config: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Chunking
|
||||
# Chunking (direct fields)
|
||||
chunking_strategy: Union[str, ChunkingStrategy] = ChunkingStrategy.SEMANTIC
|
||||
chunk_size: int = 1000
|
||||
chunk_overlap: int = 200
|
||||
|
||||
# Chunker config dict (alternative to direct fields)
|
||||
# Supports: {"type": "semantic", "chunk_size": 512, ...}
|
||||
chunker: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Retrieval
|
||||
retrieval_k: int = 5
|
||||
retrieval_threshold: float = 0.0
|
||||
@@ -235,6 +248,10 @@ class KnowledgeConfig:
|
||||
# Auto-retrieval (inject context automatically)
|
||||
auto_retrieve: bool = True
|
||||
|
||||
# Vector store config dict (for vector database settings)
|
||||
# Supports: {"provider": "qdrant", "url": "...", ...}
|
||||
vector_store: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Full config dict (for advanced use)
|
||||
config: Optional[Dict[str, Any]] = None
|
||||
|
||||
@@ -247,11 +264,13 @@ class KnowledgeConfig:
|
||||
"chunking_strategy": self.chunking_strategy.value if isinstance(self.chunking_strategy, ChunkingStrategy) else self.chunking_strategy,
|
||||
"chunk_size": self.chunk_size,
|
||||
"chunk_overlap": self.chunk_overlap,
|
||||
"chunker": self.chunker,
|
||||
"retrieval_k": self.retrieval_k,
|
||||
"retrieval_threshold": self.retrieval_threshold,
|
||||
"rerank": self.rerank,
|
||||
"rerank_model": self.rerank_model,
|
||||
"auto_retrieve": self.auto_retrieve,
|
||||
"vector_store": self.vector_store,
|
||||
"config": self.config,
|
||||
}
|
||||
|
||||
|
||||
@@ -334,16 +334,11 @@ WORKFLOW_STEP_EXECUTION_PRESETS: Dict[str, Dict[str, Any]] = {
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Workflow Output Presets
|
||||
# Workflow Output Presets - DEPRECATED, use OUTPUT_PRESETS instead (DRY)
|
||||
# =============================================================================
|
||||
|
||||
WORKFLOW_OUTPUT_PRESETS: Dict[str, Dict[str, Any]] = {
|
||||
"silent": {"verbose": False, "stream": False},
|
||||
"minimal": {"verbose": False, "stream": True},
|
||||
"normal": {"verbose": False, "stream": True},
|
||||
"verbose": {"verbose": True, "stream": True},
|
||||
"debug": {"verbose": True, "stream": True},
|
||||
}
|
||||
# Workflows now use the same OUTPUT_PRESETS as Agent for consistency.
|
||||
# This alias is kept for backward compatibility only.
|
||||
WORKFLOW_OUTPUT_PRESETS = OUTPUT_PRESETS
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -269,4 +269,14 @@ def __getattr__(name: str):
|
||||
from . import artifacts
|
||||
return getattr(artifacts, name)
|
||||
|
||||
# Session Context Tracking (Agno pattern)
|
||||
if name in ("SessionContextTracker", "SessionState"):
|
||||
from . import session_tracker
|
||||
return getattr(session_tracker, name)
|
||||
|
||||
# Context Aggregation (CrewAI pattern)
|
||||
if name in ("ContextAggregator", "AggregatedContext", "create_aggregator_from_config"):
|
||||
from . import aggregator
|
||||
return getattr(aggregator, name)
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -0,0 +1,319 @@
|
||||
"""
|
||||
Context Aggregator for PraisonAI Agents.
|
||||
|
||||
Aggregates context from multiple memory sources concurrently.
|
||||
Inspired by CrewAI's ContextualMemory pattern.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AggregatedContext:
|
||||
"""Result of context aggregation."""
|
||||
context: str = ""
|
||||
sources_used: List[str] = field(default_factory=list)
|
||||
tokens_used: int = 0
|
||||
fetch_times: Dict[str, float] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"context": self.context[:500] + "..." if len(self.context) > 500 else self.context,
|
||||
"sources_used": self.sources_used,
|
||||
"tokens_used": self.tokens_used,
|
||||
"fetch_times": self.fetch_times,
|
||||
}
|
||||
|
||||
|
||||
class ContextAggregator:
|
||||
"""
|
||||
Aggregates context from multiple memory sources concurrently.
|
||||
|
||||
Implements the CrewAI ContextualMemory pattern for PraisonAI.
|
||||
Fetches from memory, knowledge, and RAG in parallel and merges results.
|
||||
|
||||
Example:
|
||||
aggregator = ContextAggregator()
|
||||
|
||||
# Register sources
|
||||
aggregator.register_source("memory", memory.search)
|
||||
aggregator.register_source("knowledge", knowledge.search)
|
||||
aggregator.register_source("rag", rag.retrieve)
|
||||
|
||||
# Aggregate context
|
||||
result = await aggregator.aggregate("user query", max_tokens=4000)
|
||||
print(result.context)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_tokens: int = 4000,
|
||||
separator: str = "\n\n---\n\n",
|
||||
include_source_labels: bool = True,
|
||||
):
|
||||
"""
|
||||
Initialize context aggregator.
|
||||
|
||||
Args:
|
||||
max_tokens: Maximum tokens for aggregated context
|
||||
separator: Separator between source contexts
|
||||
include_source_labels: Whether to label each source section
|
||||
"""
|
||||
self.max_tokens = max_tokens
|
||||
self.separator = separator
|
||||
self.include_source_labels = include_source_labels
|
||||
|
||||
# Registered context sources: name -> (fetch_fn, priority)
|
||||
self._sources: Dict[str, Tuple[Callable, int]] = {}
|
||||
|
||||
def _estimate_tokens(self, text: str) -> int:
|
||||
"""Estimate token count (4 chars per token heuristic)."""
|
||||
return len(text) // 4 + 1
|
||||
|
||||
def register_source(
|
||||
self,
|
||||
name: str,
|
||||
fetch_fn: Callable[[str], Union[str, List[Dict[str, Any]]]],
|
||||
priority: int = 50,
|
||||
) -> None:
|
||||
"""
|
||||
Register a context source.
|
||||
|
||||
Args:
|
||||
name: Source name (e.g., "memory", "knowledge", "rag")
|
||||
fetch_fn: Function that takes query and returns context
|
||||
priority: Priority (lower = first, 0-100)
|
||||
"""
|
||||
self._sources[name] = (fetch_fn, priority)
|
||||
|
||||
def unregister_source(self, name: str) -> None:
|
||||
"""Remove a registered source."""
|
||||
self._sources.pop(name, None)
|
||||
|
||||
@property
|
||||
def sources(self) -> List[str]:
|
||||
"""Get list of registered source names."""
|
||||
return list(self._sources.keys())
|
||||
|
||||
async def _fetch_source(
|
||||
self,
|
||||
name: str,
|
||||
query: str,
|
||||
) -> Tuple[str, Optional[str], float]:
|
||||
"""
|
||||
Fetch context from a single source.
|
||||
|
||||
Returns: (context_str, source_name, fetch_time)
|
||||
"""
|
||||
import time
|
||||
|
||||
fetch_fn, _ = self._sources.get(name, (None, 0))
|
||||
if not fetch_fn:
|
||||
return "", None, 0.0
|
||||
|
||||
start = time.time()
|
||||
try:
|
||||
# Call the fetch function
|
||||
if asyncio.iscoroutinefunction(fetch_fn):
|
||||
result = await fetch_fn(query)
|
||||
else:
|
||||
# Run sync function in executor
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(None, fetch_fn, query)
|
||||
|
||||
# Convert result to string
|
||||
if isinstance(result, str):
|
||||
context = result
|
||||
elif isinstance(result, list):
|
||||
# Handle list of results (e.g., from memory search)
|
||||
parts = []
|
||||
for item in result:
|
||||
if isinstance(item, dict):
|
||||
text = item.get("text") or item.get("memory") or item.get("content", "")
|
||||
if text:
|
||||
parts.append(str(text))
|
||||
elif isinstance(item, str):
|
||||
parts.append(item)
|
||||
context = "\n".join(parts)
|
||||
else:
|
||||
context = str(result) if result else ""
|
||||
|
||||
elapsed = time.time() - start
|
||||
return context, name, elapsed
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch from {name}: {e}")
|
||||
return "", None, time.time() - start
|
||||
|
||||
async def aggregate(
|
||||
self,
|
||||
query: str,
|
||||
sources: Optional[List[str]] = None,
|
||||
max_tokens: Optional[int] = None,
|
||||
) -> AggregatedContext:
|
||||
"""
|
||||
Aggregate context from multiple sources concurrently.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
sources: Specific sources to use (default: all registered)
|
||||
max_tokens: Override max tokens
|
||||
|
||||
Returns:
|
||||
AggregatedContext with merged results
|
||||
"""
|
||||
max_tokens = max_tokens or self.max_tokens
|
||||
sources_to_use = sources or list(self._sources.keys())
|
||||
|
||||
if not sources_to_use:
|
||||
return AggregatedContext()
|
||||
|
||||
# Filter to registered sources only
|
||||
sources_to_use = [s for s in sources_to_use if s in self._sources]
|
||||
|
||||
if not sources_to_use:
|
||||
return AggregatedContext()
|
||||
|
||||
# Fetch from all sources concurrently
|
||||
tasks = [
|
||||
self._fetch_source(name, query)
|
||||
for name in sources_to_use
|
||||
]
|
||||
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Process results
|
||||
contexts: List[Tuple[str, str, float, int]] = [] # (context, name, time, priority)
|
||||
fetch_times: Dict[str, float] = {}
|
||||
|
||||
for result in results:
|
||||
if isinstance(result, Exception):
|
||||
logger.warning(f"Source fetch failed: {result}")
|
||||
continue
|
||||
|
||||
context, name, elapsed = result
|
||||
if context and name:
|
||||
_, priority = self._sources.get(name, (None, 50))
|
||||
contexts.append((context, name, elapsed, priority))
|
||||
fetch_times[name] = elapsed
|
||||
|
||||
# Sort by priority (lower = first)
|
||||
contexts.sort(key=lambda x: x[3])
|
||||
|
||||
# Merge and truncate to fit token budget
|
||||
merged_parts = []
|
||||
total_tokens = 0
|
||||
sources_used = []
|
||||
separator_tokens = self._estimate_tokens(self.separator)
|
||||
|
||||
for context, name, _, _ in contexts:
|
||||
context_tokens = self._estimate_tokens(context)
|
||||
|
||||
# Check if we can fit this context
|
||||
if total_tokens + context_tokens + separator_tokens > max_tokens:
|
||||
# Try to fit partial
|
||||
remaining = max_tokens - total_tokens - separator_tokens
|
||||
if remaining > 100:
|
||||
# Truncate context
|
||||
chars_to_keep = int(remaining * 4 * 0.9)
|
||||
truncated = context[:chars_to_keep] + "..."
|
||||
|
||||
if self.include_source_labels:
|
||||
merged_parts.append(f"**{name}**:\n{truncated}")
|
||||
else:
|
||||
merged_parts.append(truncated)
|
||||
|
||||
sources_used.append(name)
|
||||
total_tokens += self._estimate_tokens(truncated)
|
||||
break
|
||||
|
||||
if self.include_source_labels:
|
||||
merged_parts.append(f"**{name}**:\n{context}")
|
||||
else:
|
||||
merged_parts.append(context)
|
||||
|
||||
sources_used.append(name)
|
||||
total_tokens += context_tokens + separator_tokens
|
||||
|
||||
merged_context = self.separator.join(merged_parts)
|
||||
|
||||
return AggregatedContext(
|
||||
context=merged_context,
|
||||
sources_used=sources_used,
|
||||
tokens_used=total_tokens,
|
||||
fetch_times=fetch_times,
|
||||
)
|
||||
|
||||
def aggregate_sync(
|
||||
self,
|
||||
query: str,
|
||||
sources: Optional[List[str]] = None,
|
||||
max_tokens: Optional[int] = None,
|
||||
) -> AggregatedContext:
|
||||
"""
|
||||
Synchronous version of aggregate.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
sources: Specific sources to use
|
||||
max_tokens: Override max tokens
|
||||
|
||||
Returns:
|
||||
AggregatedContext with merged results
|
||||
"""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
# Create a new loop for sync execution
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor() as pool:
|
||||
future = pool.submit(
|
||||
asyncio.run,
|
||||
self.aggregate(query, sources, max_tokens)
|
||||
)
|
||||
return future.result()
|
||||
else:
|
||||
return loop.run_until_complete(
|
||||
self.aggregate(query, sources, max_tokens)
|
||||
)
|
||||
except RuntimeError:
|
||||
# No event loop, create one
|
||||
return asyncio.run(self.aggregate(query, sources, max_tokens))
|
||||
|
||||
|
||||
def create_aggregator_from_config(
|
||||
memory = None,
|
||||
knowledge = None,
|
||||
rag = None,
|
||||
max_tokens: int = 4000,
|
||||
) -> ContextAggregator:
|
||||
"""
|
||||
Create a ContextAggregator with standard sources.
|
||||
|
||||
Args:
|
||||
memory: Memory instance with search() method
|
||||
knowledge: Knowledge instance with search() method
|
||||
rag: RAG instance with retrieve() method
|
||||
max_tokens: Maximum tokens for aggregated context
|
||||
|
||||
Returns:
|
||||
Configured ContextAggregator
|
||||
"""
|
||||
aggregator = ContextAggregator(max_tokens=max_tokens)
|
||||
|
||||
if memory and hasattr(memory, 'search_short_term'):
|
||||
aggregator.register_source("memory", memory.search_short_term, priority=10)
|
||||
|
||||
if knowledge and hasattr(knowledge, 'search'):
|
||||
aggregator.register_source("knowledge", knowledge.search, priority=20)
|
||||
|
||||
if rag and hasattr(rag, 'retrieve'):
|
||||
aggregator.register_source("rag", rag.retrieve, priority=30)
|
||||
|
||||
return aggregator
|
||||
@@ -9,6 +9,12 @@ Key features:
|
||||
- Limited serial turns (max 4) for fast response
|
||||
- Restricted tool set (grep, glob, read) for safety
|
||||
- Returns files + line ranges (no summarization)
|
||||
|
||||
Optional optimizations (no performance impact when disabled):
|
||||
- Ripgrep backend: Use `search_backend='ripgrep'` for faster grep
|
||||
- Async file I/O: Uses aiofiles if installed
|
||||
- Incremental indexing: Use `enable_indexing=True`
|
||||
- Context compression: Use `compression='truncate'` or `compression='smart'`
|
||||
"""
|
||||
|
||||
# Lazy imports to avoid impacting package load time
|
||||
@@ -29,6 +35,28 @@ def __getattr__(name):
|
||||
elif name == "FastContextAgent":
|
||||
from praisonaiagents.context.fast.fast_context_agent import FastContextAgent
|
||||
return FastContextAgent
|
||||
# New optimization exports
|
||||
elif name == "get_search_backend":
|
||||
from praisonaiagents.context.fast.search_backends import get_search_backend
|
||||
return get_search_backend
|
||||
elif name == "PythonSearchBackend":
|
||||
from praisonaiagents.context.fast.search_backends import PythonSearchBackend
|
||||
return PythonSearchBackend
|
||||
elif name == "RipgrepBackend":
|
||||
from praisonaiagents.context.fast.search_backends import RipgrepBackend
|
||||
return RipgrepBackend
|
||||
elif name == "FileIndex":
|
||||
from praisonaiagents.context.fast.index_manager import FileIndex
|
||||
return FileIndex
|
||||
elif name == "get_compressor":
|
||||
from praisonaiagents.context.fast.compressor import get_compressor
|
||||
return get_compressor
|
||||
elif name == "TruncateCompressor":
|
||||
from praisonaiagents.context.fast.compressor import TruncateCompressor
|
||||
return TruncateCompressor
|
||||
elif name == "SmartCompressor":
|
||||
from praisonaiagents.context.fast.compressor import SmartCompressor
|
||||
return SmartCompressor
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
__all__ = [
|
||||
@@ -37,4 +65,13 @@ __all__ = [
|
||||
"FileMatch",
|
||||
"LineRange",
|
||||
"FastContextAgent",
|
||||
# Optimization exports
|
||||
"get_search_backend",
|
||||
"PythonSearchBackend",
|
||||
"RipgrepBackend",
|
||||
"FileIndex",
|
||||
"get_compressor",
|
||||
"TruncateCompressor",
|
||||
"SmartCompressor",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
Async file operations for FastContext.
|
||||
|
||||
Provides optional aiofiles integration with graceful fallback:
|
||||
- async_read_file: Async file reading (uses aiofiles if available)
|
||||
- async_read_lines: Async line reading with range support
|
||||
|
||||
Design principles:
|
||||
- Lazy import of aiofiles (optional dependency)
|
||||
- Graceful fallback to sync operations in executor
|
||||
- No import-time overhead
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Lazy availability check
|
||||
_AIOFILES_AVAILABLE: Optional[bool] = None
|
||||
|
||||
|
||||
def _check_aiofiles() -> bool:
|
||||
"""Lazy check for aiofiles availability.
|
||||
|
||||
Returns:
|
||||
True if aiofiles is installed and importable
|
||||
"""
|
||||
global _AIOFILES_AVAILABLE
|
||||
if _AIOFILES_AVAILABLE is None:
|
||||
try:
|
||||
import aiofiles # noqa: F401
|
||||
_AIOFILES_AVAILABLE = True
|
||||
logger.debug("aiofiles available")
|
||||
except ImportError:
|
||||
_AIOFILES_AVAILABLE = False
|
||||
logger.debug("aiofiles not available, using sync fallback")
|
||||
return _AIOFILES_AVAILABLE
|
||||
|
||||
|
||||
def _sync_read_file(
|
||||
path: str,
|
||||
encoding: str = 'utf-8',
|
||||
errors: str = 'ignore'
|
||||
) -> str:
|
||||
"""Sync file read for fallback."""
|
||||
with open(path, 'r', encoding=encoding, errors=errors) as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def _sync_read_lines(
|
||||
path: str,
|
||||
start_line: Optional[int] = None,
|
||||
end_line: Optional[int] = None,
|
||||
encoding: str = 'utf-8',
|
||||
errors: str = 'ignore'
|
||||
) -> List[str]:
|
||||
"""Sync line read for fallback."""
|
||||
with open(path, 'r', encoding=encoding, errors=errors) as f:
|
||||
lines = f.readlines()
|
||||
|
||||
if start_line is not None and end_line is not None:
|
||||
# Convert to 0-indexed
|
||||
start_idx = max(0, start_line - 1)
|
||||
end_idx = min(len(lines), end_line)
|
||||
return [line.rstrip('\n\r') for line in lines[start_idx:end_idx]]
|
||||
elif start_line is not None:
|
||||
start_idx = max(0, start_line - 1)
|
||||
return [line.rstrip('\n\r') for line in lines[start_idx:]]
|
||||
elif end_line is not None:
|
||||
end_idx = min(len(lines), end_line)
|
||||
return [line.rstrip('\n\r') for line in lines[:end_idx]]
|
||||
else:
|
||||
return [line.rstrip('\n\r') for line in lines]
|
||||
|
||||
|
||||
async def async_read_file(
|
||||
path: str,
|
||||
encoding: str = 'utf-8',
|
||||
errors: str = 'ignore'
|
||||
) -> str:
|
||||
"""Read file asynchronously.
|
||||
|
||||
Uses aiofiles if available, otherwise runs sync read in executor.
|
||||
|
||||
Args:
|
||||
path: File path to read
|
||||
encoding: Text encoding
|
||||
errors: Error handling ('ignore', 'strict', 'replace')
|
||||
|
||||
Returns:
|
||||
File content as string
|
||||
"""
|
||||
if _check_aiofiles():
|
||||
import aiofiles
|
||||
async with aiofiles.open(path, 'r', encoding=encoding, errors=errors) as f:
|
||||
return await f.read()
|
||||
else:
|
||||
# Fallback: run sync read in thread executor
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: _sync_read_file(path, encoding, errors)
|
||||
)
|
||||
|
||||
|
||||
async def async_read_lines(
|
||||
path: str,
|
||||
start_line: Optional[int] = None,
|
||||
end_line: Optional[int] = None,
|
||||
encoding: str = 'utf-8',
|
||||
errors: str = 'ignore'
|
||||
) -> List[str]:
|
||||
"""Read specific lines from file asynchronously.
|
||||
|
||||
Args:
|
||||
path: File path to read
|
||||
start_line: First line to read (1-indexed, inclusive)
|
||||
end_line: Last line to read (1-indexed, inclusive)
|
||||
encoding: Text encoding
|
||||
errors: Error handling
|
||||
|
||||
Returns:
|
||||
List of lines (without line endings)
|
||||
"""
|
||||
if _check_aiofiles():
|
||||
import aiofiles
|
||||
async with aiofiles.open(path, 'r', encoding=encoding, errors=errors) as f:
|
||||
lines = await f.readlines()
|
||||
|
||||
if start_line is not None and end_line is not None:
|
||||
start_idx = max(0, start_line - 1)
|
||||
end_idx = min(len(lines), end_line)
|
||||
return [line.rstrip('\n\r') for line in lines[start_idx:end_idx]]
|
||||
elif start_line is not None:
|
||||
start_idx = max(0, start_line - 1)
|
||||
return [line.rstrip('\n\r') for line in lines[start_idx:]]
|
||||
elif end_line is not None:
|
||||
end_idx = min(len(lines), end_line)
|
||||
return [line.rstrip('\n\r') for line in lines[:end_idx]]
|
||||
else:
|
||||
return [line.rstrip('\n\r') for line in lines]
|
||||
else:
|
||||
# Fallback
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: _sync_read_lines(path, start_line, end_line, encoding, errors)
|
||||
)
|
||||
|
||||
|
||||
def is_aiofiles_available() -> bool:
|
||||
"""Check if aiofiles is available for async operations.
|
||||
|
||||
Returns:
|
||||
True if aiofiles is installed
|
||||
"""
|
||||
return _check_aiofiles()
|
||||
@@ -0,0 +1,254 @@
|
||||
"""
|
||||
Context compressor for FastContext.
|
||||
|
||||
Provides token-aware context compression:
|
||||
- TruncateCompressor: Simple token-based truncation
|
||||
- SmartCompressor: Preserves important content
|
||||
- LLMLinguaCompressor: Optional LLMLingua integration (wrapper only)
|
||||
|
||||
Design principles:
|
||||
- No external dependencies for basic compression
|
||||
- Lazy import for optional LLMLingua
|
||||
- Preserves most relevant content
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Protocol, Optional, List
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Lazy availability check for llmlingua
|
||||
_LLMLINGUA_AVAILABLE: Optional[bool] = None
|
||||
|
||||
|
||||
def _check_llmlingua() -> bool:
|
||||
"""Lazy check for llmlingua availability."""
|
||||
global _LLMLINGUA_AVAILABLE
|
||||
if _LLMLINGUA_AVAILABLE is None:
|
||||
try:
|
||||
import llmlingua # noqa: F401
|
||||
_LLMLINGUA_AVAILABLE = True
|
||||
except ImportError:
|
||||
_LLMLINGUA_AVAILABLE = False
|
||||
return _LLMLINGUA_AVAILABLE
|
||||
|
||||
|
||||
class ContextCompressor(Protocol):
|
||||
"""Protocol for context compression."""
|
||||
|
||||
def compress(self, text: str, max_tokens: int) -> str:
|
||||
"""Compress text to fit within token budget.
|
||||
|
||||
Args:
|
||||
text: Text to compress
|
||||
max_tokens: Maximum tokens allowed
|
||||
|
||||
Returns:
|
||||
Compressed text
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@dataclass
|
||||
class CompressionConfig:
|
||||
"""Configuration for context compression."""
|
||||
chars_per_token: float = 4.0 # Conservative estimate
|
||||
preserve_start_lines: int = 5 # Preserve first N lines
|
||||
preserve_end_lines: int = 3 # Preserve last N lines
|
||||
truncation_marker: str = "\n\n... [content truncated] ...\n\n"
|
||||
|
||||
|
||||
class TruncateCompressor:
|
||||
"""Simple token-aware truncation compressor.
|
||||
|
||||
Preserves start and end of content, truncates middle.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[CompressionConfig] = None):
|
||||
self.config = config or CompressionConfig()
|
||||
|
||||
def estimate_tokens(self, text: str) -> int:
|
||||
"""Estimate token count."""
|
||||
return int(len(text) / self.config.chars_per_token)
|
||||
|
||||
def compress(self, text: str, max_tokens: int) -> str:
|
||||
"""Compress text by truncating middle.
|
||||
|
||||
Args:
|
||||
text: Text to compress
|
||||
max_tokens: Maximum tokens allowed
|
||||
|
||||
Returns:
|
||||
Compressed text
|
||||
"""
|
||||
current_tokens = self.estimate_tokens(text)
|
||||
|
||||
if current_tokens <= max_tokens:
|
||||
return text
|
||||
|
||||
# Calculate how much to keep
|
||||
max_chars = int(max_tokens * self.config.chars_per_token)
|
||||
marker_len = len(self.config.truncation_marker)
|
||||
available_chars = max_chars - marker_len
|
||||
|
||||
if available_chars <= 0:
|
||||
return text[:max_chars]
|
||||
|
||||
# Split into lines for smarter truncation
|
||||
lines = text.split('\n')
|
||||
|
||||
# Preserve start and end lines
|
||||
start_lines = lines[:self.config.preserve_start_lines]
|
||||
end_lines = lines[-self.config.preserve_end_lines:] if self.config.preserve_end_lines > 0 else []
|
||||
|
||||
start_text = '\n'.join(start_lines)
|
||||
end_text = '\n'.join(end_lines)
|
||||
|
||||
# Calculate remaining budget for middle
|
||||
preserved_len = len(start_text) + len(end_text)
|
||||
middle_budget = available_chars - preserved_len
|
||||
|
||||
if middle_budget <= 0:
|
||||
# Not enough room, just truncate at char level
|
||||
half = available_chars // 2
|
||||
return text[:half] + self.config.truncation_marker + text[-half:]
|
||||
|
||||
# Take some middle content
|
||||
middle_lines = lines[self.config.preserve_start_lines:-self.config.preserve_end_lines] if self.config.preserve_end_lines > 0 else lines[self.config.preserve_start_lines:]
|
||||
middle_text = '\n'.join(middle_lines)
|
||||
|
||||
if len(middle_text) <= middle_budget:
|
||||
return text # Actually fits
|
||||
|
||||
# Truncate middle
|
||||
middle_truncated = middle_text[:middle_budget // 2]
|
||||
|
||||
return start_text + '\n' + middle_truncated + self.config.truncation_marker + end_text
|
||||
|
||||
|
||||
class SmartCompressor:
|
||||
"""Smart compressor that preserves important content.
|
||||
|
||||
Uses heuristics to identify important lines (definitions, headers, etc.)
|
||||
"""
|
||||
|
||||
IMPORTANT_PATTERNS = [
|
||||
'def ', 'class ', 'async def ', # Function/class definitions
|
||||
'import ', 'from ', # Imports
|
||||
'# ', '"""', "'''", # Comments/docstrings
|
||||
'if __name__', 'return ', # Key statements
|
||||
]
|
||||
|
||||
def __init__(self, config: Optional[CompressionConfig] = None):
|
||||
self.config = config or CompressionConfig()
|
||||
self._fallback = TruncateCompressor(config)
|
||||
|
||||
def estimate_tokens(self, text: str) -> int:
|
||||
"""Estimate token count."""
|
||||
return int(len(text) / self.config.chars_per_token)
|
||||
|
||||
def _score_line(self, line: str) -> int:
|
||||
"""Score a line's importance."""
|
||||
score = 0
|
||||
stripped = line.strip()
|
||||
|
||||
for pattern in self.IMPORTANT_PATTERNS:
|
||||
if stripped.startswith(pattern):
|
||||
score += 10
|
||||
|
||||
# Penalize empty lines
|
||||
if not stripped:
|
||||
score -= 5
|
||||
|
||||
return score
|
||||
|
||||
def compress(self, text: str, max_tokens: int) -> str:
|
||||
"""Compress text preserving important lines.
|
||||
|
||||
Args:
|
||||
text: Text to compress
|
||||
max_tokens: Maximum tokens allowed
|
||||
|
||||
Returns:
|
||||
Compressed text
|
||||
"""
|
||||
current_tokens = self.estimate_tokens(text)
|
||||
|
||||
if current_tokens <= max_tokens:
|
||||
return text
|
||||
|
||||
lines = text.split('\n')
|
||||
|
||||
if len(lines) <= 10:
|
||||
# Too few lines for smart compression
|
||||
return self._fallback.compress(text, max_tokens)
|
||||
|
||||
# Score each line
|
||||
scored_lines = [(i, line, self._score_line(line)) for i, line in enumerate(lines)]
|
||||
|
||||
# Sort by score (keep highest)
|
||||
sorted_by_score = sorted(scored_lines, key=lambda x: x[2], reverse=True)
|
||||
|
||||
# Select lines until we hit token budget
|
||||
max_chars = int(max_tokens * self.config.chars_per_token)
|
||||
selected_indices = set()
|
||||
current_chars = len(self.config.truncation_marker)
|
||||
|
||||
for idx, line, score in sorted_by_score:
|
||||
line_chars = len(line) + 1 # +1 for newline
|
||||
if current_chars + line_chars <= max_chars:
|
||||
selected_indices.add(idx)
|
||||
current_chars += line_chars
|
||||
|
||||
# Rebuild text in original order
|
||||
result_lines = []
|
||||
last_included = -1
|
||||
|
||||
for i in range(len(lines)):
|
||||
if i in selected_indices:
|
||||
if last_included >= 0 and i - last_included > 1:
|
||||
result_lines.append("...")
|
||||
result_lines.append(lines[i])
|
||||
last_included = i
|
||||
|
||||
return '\n'.join(result_lines)
|
||||
|
||||
|
||||
def get_compressor(compressor_type: str = "truncate") -> ContextCompressor:
|
||||
"""Get a context compressor by type.
|
||||
|
||||
Args:
|
||||
compressor_type: One of "truncate", "smart", "llmlingua"
|
||||
|
||||
Returns:
|
||||
ContextCompressor instance
|
||||
|
||||
Raises:
|
||||
ValueError: If compressor_type is invalid
|
||||
"""
|
||||
if compressor_type == "truncate":
|
||||
return TruncateCompressor()
|
||||
|
||||
elif compressor_type == "smart":
|
||||
return SmartCompressor()
|
||||
|
||||
elif compressor_type == "llmlingua":
|
||||
if not _check_llmlingua():
|
||||
logger.warning("llmlingua not available, using smart compressor")
|
||||
return SmartCompressor()
|
||||
# LLMLingua integration would go here
|
||||
# For now, fallback to smart
|
||||
return SmartCompressor()
|
||||
|
||||
else:
|
||||
raise ValueError(f"Invalid compressor type: {compressor_type}. Must be 'truncate', 'smart', or 'llmlingua'")
|
||||
|
||||
|
||||
def is_llmlingua_available() -> bool:
|
||||
"""Check if llmlingua is available.
|
||||
|
||||
Returns:
|
||||
True if llmlingua is installed
|
||||
"""
|
||||
return _check_llmlingua()
|
||||
@@ -58,6 +58,7 @@ class FastContext:
|
||||
ENV_TIMEOUT = "FAST_CONTEXT_TIMEOUT"
|
||||
ENV_CACHE_ENABLED = "FAST_CONTEXT_CACHE"
|
||||
ENV_CACHE_TTL = "FAST_CONTEXT_CACHE_TTL"
|
||||
ENV_SEARCH_BACKEND = "FAST_CONTEXT_BACKEND"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -68,7 +69,12 @@ class FastContext:
|
||||
timeout: Optional[float] = None,
|
||||
cache_enabled: Optional[bool] = None,
|
||||
cache_ttl: Optional[int] = None,
|
||||
verbose: bool = False
|
||||
verbose: bool = False,
|
||||
# New optimization parameters
|
||||
search_backend: Optional[str] = None,
|
||||
enable_indexing: bool = False,
|
||||
index_path: Optional[str] = None,
|
||||
compression: Optional[str] = None
|
||||
):
|
||||
"""Initialize FastContext.
|
||||
|
||||
@@ -82,6 +88,7 @@ class FastContext:
|
||||
FAST_CONTEXT_TIMEOUT: Timeout in seconds (default: 30.0)
|
||||
FAST_CONTEXT_CACHE: Enable caching (default: true)
|
||||
FAST_CONTEXT_CACHE_TTL: Cache TTL in seconds (default: 300)
|
||||
FAST_CONTEXT_BACKEND: Search backend (default: auto)
|
||||
|
||||
Args:
|
||||
workspace_path: Root directory for searches (defaults to cwd)
|
||||
@@ -92,6 +99,10 @@ class FastContext:
|
||||
cache_enabled: Whether to cache search results
|
||||
cache_ttl: Cache time-to-live in seconds
|
||||
verbose: If True, print debug information
|
||||
search_backend: Search backend ("auto", "python", "ripgrep")
|
||||
enable_indexing: Enable incremental file indexing
|
||||
index_path: Custom path for index file
|
||||
compression: Compression strategy ("truncate", "smart", None)
|
||||
"""
|
||||
self.workspace_path = os.path.abspath(workspace_path or os.getcwd())
|
||||
|
||||
@@ -108,11 +119,20 @@ class FastContext:
|
||||
|
||||
self.verbose = verbose
|
||||
|
||||
# Optimization settings (all optional with graceful fallback)
|
||||
self.search_backend = search_backend or os.environ.get(self.ENV_SEARCH_BACKEND, "auto")
|
||||
self.enable_indexing = enable_indexing
|
||||
self.index_path = index_path
|
||||
self.compression = compression
|
||||
|
||||
# Cache storage
|
||||
self._cache: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
# Agent instance (lazy loaded)
|
||||
# Lazy-loaded components
|
||||
self._agent: Optional[FastContextAgent] = None
|
||||
self._backend = None # SearchBackend instance
|
||||
self._index = None # FileIndex instance
|
||||
self._compressor = None # ContextCompressor instance
|
||||
|
||||
def _get_agent(self) -> FastContextAgent:
|
||||
"""Get or create FastContextAgent instance."""
|
||||
@@ -127,6 +147,31 @@ class FastContext:
|
||||
)
|
||||
return self._agent
|
||||
|
||||
def _get_backend(self):
|
||||
"""Get or create SearchBackend instance (lazy)."""
|
||||
if self._backend is None:
|
||||
from praisonaiagents.context.fast.search_backends import get_search_backend
|
||||
self._backend = get_search_backend(self.search_backend, workspace_path=self.workspace_path)
|
||||
return self._backend
|
||||
|
||||
def _get_index(self):
|
||||
"""Get or create FileIndex instance (lazy)."""
|
||||
if not self.enable_indexing:
|
||||
return None
|
||||
if self._index is None:
|
||||
from praisonaiagents.context.fast.index_manager import FileIndex
|
||||
self._index = FileIndex.load_or_create(self.workspace_path)
|
||||
return self._index
|
||||
|
||||
def _get_compressor(self):
|
||||
"""Get or create ContextCompressor instance (lazy)."""
|
||||
if not self.compression:
|
||||
return None
|
||||
if self._compressor is None:
|
||||
from praisonaiagents.context.fast.compressor import get_compressor
|
||||
self._compressor = get_compressor(self.compression)
|
||||
return self._compressor
|
||||
|
||||
def _get_cache_key(self, query: str, **kwargs) -> str:
|
||||
"""Generate cache key for a query."""
|
||||
key_data = {
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
"""
|
||||
Index manager for FastContext incremental indexing.
|
||||
|
||||
Provides file indexing for faster repeat searches:
|
||||
- FileIndex: Lightweight file index with mtime tracking
|
||||
- Incremental updates: Only rescan changed files
|
||||
- Optional file watcher integration (watchfiles)
|
||||
|
||||
Design principles:
|
||||
- Index stored as JSON for simplicity
|
||||
- Lazy loading of optional dependencies
|
||||
- No performance impact when disabled
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import hashlib
|
||||
import logging
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Set, List, Any
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Lazy availability check for watchfiles
|
||||
_WATCHFILES_AVAILABLE: Optional[bool] = None
|
||||
|
||||
|
||||
def _check_watchfiles() -> bool:
|
||||
"""Lazy check for watchfiles availability."""
|
||||
global _WATCHFILES_AVAILABLE
|
||||
if _WATCHFILES_AVAILABLE is None:
|
||||
try:
|
||||
import watchfiles # noqa: F401
|
||||
_WATCHFILES_AVAILABLE = True
|
||||
except ImportError:
|
||||
_WATCHFILES_AVAILABLE = False
|
||||
return _WATCHFILES_AVAILABLE
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileEntry:
|
||||
"""Entry for a single file in the index."""
|
||||
path: str
|
||||
mtime: float # Modification time
|
||||
size: int
|
||||
content_hash: Optional[str] = None # Optional for large files
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "FileEntry":
|
||||
return cls(**data)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileIndex:
|
||||
"""Lightweight file index for incremental search.
|
||||
|
||||
Tracks file modification times to avoid rescanning
|
||||
unchanged files on repeat searches.
|
||||
|
||||
Attributes:
|
||||
workspace_path: Root directory being indexed
|
||||
index_version: Version for format compatibility
|
||||
"""
|
||||
|
||||
INDEX_VERSION = 1
|
||||
INDEX_FILENAME = ".fast_context_index.json"
|
||||
|
||||
workspace_path: str
|
||||
entries: Dict[str, FileEntry] = field(default_factory=dict)
|
||||
index_version: int = INDEX_VERSION
|
||||
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||
updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||
|
||||
@property
|
||||
def index_path(self) -> str:
|
||||
"""Path to the index file."""
|
||||
return os.path.join(self.workspace_path, self.INDEX_FILENAME)
|
||||
|
||||
def needs_rescan(self, filepath: str) -> bool:
|
||||
"""Check if file needs rescan based on mtime.
|
||||
|
||||
Args:
|
||||
filepath: Absolute path to file
|
||||
|
||||
Returns:
|
||||
True if file needs rescan (new, modified, or not indexed)
|
||||
"""
|
||||
rel_path = os.path.relpath(filepath, self.workspace_path)
|
||||
|
||||
if rel_path not in self.entries:
|
||||
return True
|
||||
|
||||
entry = self.entries[rel_path]
|
||||
|
||||
try:
|
||||
stat = os.stat(filepath)
|
||||
return stat.st_mtime != entry.mtime or stat.st_size != entry.size
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
def update(self, filepath: str, content_hash: Optional[str] = None) -> None:
|
||||
"""Update index entry for file.
|
||||
|
||||
Args:
|
||||
filepath: Absolute path to file
|
||||
content_hash: Optional hash of file content
|
||||
"""
|
||||
rel_path = os.path.relpath(filepath, self.workspace_path)
|
||||
|
||||
try:
|
||||
stat = os.stat(filepath)
|
||||
self.entries[rel_path] = FileEntry(
|
||||
path=rel_path,
|
||||
mtime=stat.st_mtime,
|
||||
size=stat.st_size,
|
||||
content_hash=content_hash
|
||||
)
|
||||
self.updated_at = datetime.now().isoformat()
|
||||
except OSError as e:
|
||||
logger.debug(f"Failed to update index for {filepath}: {e}")
|
||||
|
||||
def remove(self, filepath: str) -> None:
|
||||
"""Remove file from index.
|
||||
|
||||
Args:
|
||||
filepath: Absolute or relative path
|
||||
"""
|
||||
rel_path = os.path.relpath(filepath, self.workspace_path) if os.path.isabs(filepath) else filepath
|
||||
self.entries.pop(rel_path, None)
|
||||
self.updated_at = datetime.now().isoformat()
|
||||
|
||||
def get_changed_files(self, filepaths: List[str]) -> List[str]:
|
||||
"""Get list of files that need rescan.
|
||||
|
||||
Args:
|
||||
filepaths: List of absolute file paths
|
||||
|
||||
Returns:
|
||||
List of files that need rescan
|
||||
"""
|
||||
return [fp for fp in filepaths if self.needs_rescan(fp)]
|
||||
|
||||
def save(self) -> bool:
|
||||
"""Save index to disk.
|
||||
|
||||
Returns:
|
||||
True if saved successfully
|
||||
"""
|
||||
try:
|
||||
data = {
|
||||
"version": self.index_version,
|
||||
"workspace": self.workspace_path,
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
"entries": {k: v.to_dict() for k, v in self.entries.items()}
|
||||
}
|
||||
|
||||
with open(self.index_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save index: {e}")
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def load(cls, workspace_path: str) -> Optional["FileIndex"]:
|
||||
"""Load index from disk.
|
||||
|
||||
Args:
|
||||
workspace_path: Root directory
|
||||
|
||||
Returns:
|
||||
FileIndex if loaded, None if not found or invalid
|
||||
"""
|
||||
index_path = os.path.join(workspace_path, cls.INDEX_FILENAME)
|
||||
|
||||
if not os.path.exists(index_path):
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(index_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Version check
|
||||
if data.get("version") != cls.INDEX_VERSION:
|
||||
logger.info("Index version mismatch, will rebuild")
|
||||
return None
|
||||
|
||||
entries = {
|
||||
k: FileEntry.from_dict(v)
|
||||
for k, v in data.get("entries", {}).items()
|
||||
}
|
||||
|
||||
return cls(
|
||||
workspace_path=workspace_path,
|
||||
entries=entries,
|
||||
index_version=data.get("version", cls.INDEX_VERSION),
|
||||
created_at=data.get("created_at", datetime.now().isoformat()),
|
||||
updated_at=data.get("updated_at", datetime.now().isoformat())
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load index: {e}")
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def load_or_create(cls, workspace_path: str) -> "FileIndex":
|
||||
"""Load existing index or create new one.
|
||||
|
||||
Args:
|
||||
workspace_path: Root directory
|
||||
|
||||
Returns:
|
||||
FileIndex instance
|
||||
"""
|
||||
index = cls.load(workspace_path)
|
||||
if index is None:
|
||||
index = cls(workspace_path=workspace_path)
|
||||
return index
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all index entries."""
|
||||
self.entries.clear()
|
||||
self.updated_at = datetime.now().isoformat()
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.entries)
|
||||
|
||||
|
||||
class IndexWatcher:
|
||||
"""Optional file watcher for real-time index updates.
|
||||
|
||||
Uses watchfiles if available, otherwise disabled.
|
||||
"""
|
||||
|
||||
def __init__(self, index: FileIndex):
|
||||
self.index = index
|
||||
self._watching = False
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check if file watching is available."""
|
||||
return _check_watchfiles()
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start watching for file changes."""
|
||||
if not self.is_available():
|
||||
logger.warning("watchfiles not available, file watching disabled")
|
||||
return
|
||||
|
||||
if self._watching:
|
||||
return
|
||||
|
||||
import watchfiles
|
||||
self._watching = True
|
||||
|
||||
async for changes in watchfiles.awatch(self.index.workspace_path):
|
||||
for change_type, path in changes:
|
||||
if change_type == watchfiles.Change.deleted:
|
||||
self.index.remove(path)
|
||||
else:
|
||||
self.index.update(path)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop watching."""
|
||||
self._watching = False
|
||||
|
||||
|
||||
def is_watchfiles_available() -> bool:
|
||||
"""Check if watchfiles is available for file watching.
|
||||
|
||||
Returns:
|
||||
True if watchfiles is installed
|
||||
"""
|
||||
return _check_watchfiles()
|
||||
@@ -0,0 +1,413 @@
|
||||
"""
|
||||
Search backends for FastContext.
|
||||
|
||||
Provides protocol-driven search backends:
|
||||
- PythonSearchBackend: Pure Python implementation (always available)
|
||||
- RipgrepBackend: Optional ripgrep subprocess (faster for large codebases)
|
||||
|
||||
Design principles:
|
||||
- Lazy imports for optional dependencies
|
||||
- Graceful degradation if deps not installed
|
||||
- No performance impact on default behavior
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Protocol, List, Dict, Any, Optional, runtime_checkable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SearchBackend(Protocol):
|
||||
"""Protocol for search backends.
|
||||
|
||||
All backends must implement these methods to be usable
|
||||
with FastContext.
|
||||
"""
|
||||
|
||||
def grep(
|
||||
self,
|
||||
path: str,
|
||||
pattern: str,
|
||||
is_regex: bool = False,
|
||||
case_sensitive: bool = False,
|
||||
include_patterns: Optional[List[str]] = None,
|
||||
exclude_patterns: Optional[List[str]] = None,
|
||||
max_results: int = 100,
|
||||
context_lines: int = 0
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search for pattern in files.
|
||||
|
||||
Args:
|
||||
path: Directory or file to search
|
||||
pattern: Search pattern
|
||||
is_regex: If True, treat pattern as regex
|
||||
case_sensitive: If True, case sensitive search
|
||||
include_patterns: Glob patterns to include
|
||||
exclude_patterns: Glob patterns to exclude
|
||||
max_results: Maximum results to return
|
||||
context_lines: Lines of context around matches
|
||||
|
||||
Returns:
|
||||
List of match dictionaries
|
||||
"""
|
||||
...
|
||||
|
||||
def glob(
|
||||
self,
|
||||
path: str,
|
||||
pattern: str,
|
||||
max_results: int = 100
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search for files matching glob pattern.
|
||||
|
||||
Args:
|
||||
path: Directory to search
|
||||
pattern: Glob pattern
|
||||
max_results: Maximum results
|
||||
|
||||
Returns:
|
||||
List of file info dictionaries
|
||||
"""
|
||||
...
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check if this backend is available.
|
||||
|
||||
Returns:
|
||||
True if backend can be used
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class PythonSearchBackend:
|
||||
"""Pure Python search backend using existing search_tools.
|
||||
|
||||
This is always available and serves as the default fallback.
|
||||
"""
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Python backend is always available."""
|
||||
return True
|
||||
|
||||
def grep(
|
||||
self,
|
||||
path: str,
|
||||
pattern: str,
|
||||
is_regex: bool = False,
|
||||
case_sensitive: bool = False,
|
||||
include_patterns: Optional[List[str]] = None,
|
||||
exclude_patterns: Optional[List[str]] = None,
|
||||
max_results: int = 100,
|
||||
context_lines: int = 0
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search using pure Python regex."""
|
||||
# Import existing implementation
|
||||
from praisonaiagents.context.fast.search_tools import grep_search
|
||||
|
||||
return grep_search(
|
||||
search_path=path,
|
||||
pattern=pattern,
|
||||
is_regex=is_regex,
|
||||
case_sensitive=case_sensitive,
|
||||
include_patterns=include_patterns,
|
||||
exclude_patterns=exclude_patterns,
|
||||
max_results=max_results,
|
||||
context_lines=context_lines
|
||||
)
|
||||
|
||||
def glob(
|
||||
self,
|
||||
path: str,
|
||||
pattern: str,
|
||||
max_results: int = 100
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search for files using pathlib glob."""
|
||||
from praisonaiagents.context.fast.search_tools import glob_search
|
||||
|
||||
return glob_search(
|
||||
search_path=path,
|
||||
pattern=pattern,
|
||||
max_results=max_results
|
||||
)
|
||||
|
||||
|
||||
class RipgrepBackend:
|
||||
"""Optional ripgrep backend using subprocess.
|
||||
|
||||
Faster for large codebases but requires 'rg' binary in PATH.
|
||||
Falls back to PythonSearchBackend if not available.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._available: Optional[bool] = None
|
||||
self._rg_path: Optional[str] = None
|
||||
self._fallback: Optional[PythonSearchBackend] = None
|
||||
|
||||
def _get_ripgrep_path(self) -> Optional[str]:
|
||||
"""Find ripgrep binary in PATH."""
|
||||
if self._rg_path is not None:
|
||||
return self._rg_path if self._rg_path != "" else None
|
||||
|
||||
import shutil
|
||||
self._rg_path = shutil.which("rg") or ""
|
||||
return self._rg_path if self._rg_path else None
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check if ripgrep is installed."""
|
||||
if self._available is None:
|
||||
self._available = self._get_ripgrep_path() is not None
|
||||
return self._available
|
||||
|
||||
def _get_fallback(self) -> PythonSearchBackend:
|
||||
"""Get Python fallback backend."""
|
||||
if self._fallback is None:
|
||||
self._fallback = PythonSearchBackend()
|
||||
return self._fallback
|
||||
|
||||
def grep(
|
||||
self,
|
||||
path: str,
|
||||
pattern: str,
|
||||
is_regex: bool = False,
|
||||
case_sensitive: bool = False,
|
||||
include_patterns: Optional[List[str]] = None,
|
||||
exclude_patterns: Optional[List[str]] = None,
|
||||
max_results: int = 100,
|
||||
context_lines: int = 0
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search using ripgrep subprocess."""
|
||||
if not self.is_available():
|
||||
logger.debug("Ripgrep not available, using Python fallback")
|
||||
return self._get_fallback().grep(
|
||||
path, pattern, is_regex, case_sensitive,
|
||||
include_patterns, exclude_patterns, max_results, context_lines
|
||||
)
|
||||
|
||||
import subprocess
|
||||
import json
|
||||
import os
|
||||
|
||||
rg_path = self._get_ripgrep_path()
|
||||
if not rg_path:
|
||||
return self._get_fallback().grep(
|
||||
path, pattern, is_regex, case_sensitive,
|
||||
include_patterns, exclude_patterns, max_results, context_lines
|
||||
)
|
||||
|
||||
# Build ripgrep command
|
||||
cmd = [rg_path, "--json"]
|
||||
|
||||
if not is_regex:
|
||||
cmd.append("--fixed-strings")
|
||||
|
||||
if not case_sensitive:
|
||||
cmd.append("--ignore-case")
|
||||
|
||||
if context_lines > 0:
|
||||
cmd.extend(["--context", str(context_lines)])
|
||||
|
||||
cmd.extend(["--max-count", str(max_results)])
|
||||
|
||||
# Add include patterns
|
||||
if include_patterns:
|
||||
for p in include_patterns:
|
||||
cmd.extend(["--glob", p])
|
||||
|
||||
# Add exclude patterns
|
||||
if exclude_patterns:
|
||||
for p in exclude_patterns:
|
||||
cmd.extend(["--glob", f"!{p}"])
|
||||
|
||||
cmd.append(pattern)
|
||||
cmd.append(path)
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
results = []
|
||||
for line in result.stdout.splitlines():
|
||||
try:
|
||||
data = json.loads(line)
|
||||
if data.get("type") == "match":
|
||||
match_data = data.get("data", {})
|
||||
results.append({
|
||||
"path": match_data.get("path", {}).get("text", ""),
|
||||
"absolute_path": os.path.join(path, match_data.get("path", {}).get("text", "")),
|
||||
"line_number": match_data.get("line_number", 0),
|
||||
"content": match_data.get("lines", {}).get("text", "").rstrip(),
|
||||
})
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return results[:max_results]
|
||||
|
||||
except (subprocess.TimeoutExpired, subprocess.SubprocessError) as e:
|
||||
logger.warning(f"Ripgrep failed: {e}, falling back to Python")
|
||||
return self._get_fallback().grep(
|
||||
path, pattern, is_regex, case_sensitive,
|
||||
include_patterns, exclude_patterns, max_results, context_lines
|
||||
)
|
||||
|
||||
def glob(
|
||||
self,
|
||||
path: str,
|
||||
pattern: str,
|
||||
max_results: int = 100
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Glob is not well-supported by ripgrep, use Python."""
|
||||
return self._get_fallback().glob(path, pattern, max_results)
|
||||
|
||||
|
||||
def _count_files_fast(path: str, limit: int = 1000) -> int:
|
||||
"""Quickly count files in a directory (stops at limit).
|
||||
|
||||
Args:
|
||||
path: Directory to count files in
|
||||
limit: Stop counting after this many files
|
||||
|
||||
Returns:
|
||||
Number of files found (capped at limit)
|
||||
"""
|
||||
import os
|
||||
|
||||
count = 0
|
||||
try:
|
||||
for root, dirs, files in os.walk(path):
|
||||
# Skip common non-code directories
|
||||
dirs[:] = [d for d in dirs if d not in {
|
||||
'.git', 'node_modules', 'venv', '.venv', '__pycache__',
|
||||
'site-packages', '.tox', 'dist', 'build', '.eggs'
|
||||
}]
|
||||
count += len(files)
|
||||
if count >= limit:
|
||||
return limit
|
||||
except Exception:
|
||||
pass
|
||||
return count
|
||||
|
||||
|
||||
# Threshold for switching from Python to Ripgrep
|
||||
# Based on profiling: Python is faster for <500 files due to subprocess overhead
|
||||
RIPGREP_FILE_THRESHOLD = 500
|
||||
|
||||
|
||||
class SmartBackend:
|
||||
"""Smart backend that auto-selects Python or Ripgrep based on codebase size.
|
||||
|
||||
Uses Python for small codebases (<500 files) where subprocess overhead
|
||||
is significant, and Ripgrep for larger codebases where its speed advantage
|
||||
(30-40x faster) outweighs the ~10ms startup overhead.
|
||||
"""
|
||||
|
||||
def __init__(self, workspace_path: Optional[str] = None):
|
||||
self._workspace_path = workspace_path
|
||||
self._python = PythonSearchBackend()
|
||||
self._ripgrep: Optional[RipgrepBackend] = None
|
||||
self._file_count: Optional[int] = None
|
||||
self._use_ripgrep: Optional[bool] = None
|
||||
|
||||
def _get_ripgrep(self) -> RipgrepBackend:
|
||||
"""Lazy-load ripgrep backend."""
|
||||
if self._ripgrep is None:
|
||||
self._ripgrep = RipgrepBackend()
|
||||
return self._ripgrep
|
||||
|
||||
def _should_use_ripgrep(self, path: str) -> bool:
|
||||
"""Determine if ripgrep should be used based on codebase size."""
|
||||
# Check if ripgrep is available
|
||||
rg = self._get_ripgrep()
|
||||
if not rg.is_available():
|
||||
return False
|
||||
|
||||
# Count files if not already counted for this path
|
||||
if self._file_count is None or self._workspace_path != path:
|
||||
self._workspace_path = path
|
||||
self._file_count = _count_files_fast(path, limit=RIPGREP_FILE_THRESHOLD + 1)
|
||||
self._use_ripgrep = self._file_count >= RIPGREP_FILE_THRESHOLD
|
||||
|
||||
if self._use_ripgrep:
|
||||
logger.debug(f"Auto-selected ripgrep backend ({self._file_count}+ files)")
|
||||
else:
|
||||
logger.debug(f"Auto-selected Python backend ({self._file_count} files)")
|
||||
|
||||
return self._use_ripgrep or False
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Smart backend is always available."""
|
||||
return True
|
||||
|
||||
def grep(
|
||||
self,
|
||||
path: str,
|
||||
pattern: str,
|
||||
is_regex: bool = False,
|
||||
case_sensitive: bool = False,
|
||||
include_patterns: Optional[List[str]] = None,
|
||||
exclude_patterns: Optional[List[str]] = None,
|
||||
max_results: int = 100,
|
||||
context_lines: int = 0
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search using auto-selected backend."""
|
||||
if self._should_use_ripgrep(path):
|
||||
return self._get_ripgrep().grep(
|
||||
path, pattern, is_regex, case_sensitive,
|
||||
include_patterns, exclude_patterns, max_results, context_lines
|
||||
)
|
||||
return self._python.grep(
|
||||
path, pattern, is_regex, case_sensitive,
|
||||
include_patterns, exclude_patterns, max_results, context_lines
|
||||
)
|
||||
|
||||
def glob(
|
||||
self,
|
||||
path: str,
|
||||
pattern: str,
|
||||
max_results: int = 100
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Glob always uses Python (ripgrep doesn't support glob well)."""
|
||||
return self._python.glob(path, pattern, max_results)
|
||||
|
||||
|
||||
def get_search_backend(
|
||||
backend_type: str = "auto",
|
||||
workspace_path: Optional[str] = None
|
||||
) -> SearchBackend:
|
||||
"""Get a search backend by type.
|
||||
|
||||
Args:
|
||||
backend_type: One of "auto", "python", "ripgrep"
|
||||
workspace_path: Path to workspace (used for auto detection)
|
||||
|
||||
Returns:
|
||||
SearchBackend instance
|
||||
|
||||
Raises:
|
||||
ValueError: If backend_type is invalid
|
||||
|
||||
Performance notes:
|
||||
- "python": Best for small codebases (<500 files), no subprocess overhead
|
||||
- "ripgrep": Best for large codebases (500+ files), 30-40x faster
|
||||
- "auto": Automatically selects based on codebase size
|
||||
"""
|
||||
if backend_type == "python":
|
||||
return PythonSearchBackend()
|
||||
|
||||
elif backend_type == "ripgrep":
|
||||
backend = RipgrepBackend()
|
||||
if not backend.is_available():
|
||||
logger.warning("Ripgrep not available, using Python fallback")
|
||||
return PythonSearchBackend()
|
||||
return backend
|
||||
|
||||
elif backend_type == "auto":
|
||||
# Use SmartBackend that auto-selects based on codebase size
|
||||
return SmartBackend(workspace_path)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Invalid backend type: {backend_type}. Must be 'auto', 'python', or 'ripgrep'")
|
||||
@@ -245,6 +245,20 @@ class ContextConfig:
|
||||
Complete context management configuration.
|
||||
|
||||
Merges settings from CLI flags, env vars, and config files.
|
||||
|
||||
Example:
|
||||
# Enable with defaults
|
||||
agent = Agent(instructions="...", context=True)
|
||||
|
||||
# Custom configuration
|
||||
agent = Agent(
|
||||
instructions="...",
|
||||
context=ContextConfig(
|
||||
auto_compact=True,
|
||||
session_tracking=True, # Track goal/plan/progress
|
||||
aggregate_memory=True, # Concurrent multi-memory fetch
|
||||
)
|
||||
)
|
||||
"""
|
||||
# Auto-compaction
|
||||
auto_compact: bool = True
|
||||
@@ -266,6 +280,22 @@ class ContextConfig:
|
||||
# Sliding window
|
||||
keep_recent_turns: int = 5
|
||||
|
||||
# Session tracking (Agno pattern)
|
||||
session_tracking: bool = False # Enable goal/plan/progress tracking
|
||||
track_summary: bool = True # Auto-extract conversation summary
|
||||
track_goal: bool = True # Track user's objective
|
||||
track_plan: bool = True # Track steps to achieve goal
|
||||
track_progress: bool = True # Track completed steps
|
||||
|
||||
# Multi-memory aggregation (CrewAI pattern)
|
||||
aggregate_memory: bool = False # Enable concurrent multi-source fetch
|
||||
aggregate_sources: List[str] = field(default_factory=lambda: [
|
||||
"memory", # Short-term memory
|
||||
"knowledge", # Long-term knowledge base
|
||||
"rag", # RAG retrieval
|
||||
])
|
||||
aggregate_max_tokens: int = 4000 # Max tokens for aggregated context
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
result = asdict(self)
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
"""
|
||||
Session Context Tracker for PraisonAI Agents.
|
||||
|
||||
Tracks session state (summary, goal, plan, progress) across conversation turns.
|
||||
Inspired by Agno's SessionContextStore pattern.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Any, Dict, List, Optional
|
||||
from datetime import datetime
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionState:
|
||||
"""
|
||||
Session state tracking data.
|
||||
|
||||
Tracks:
|
||||
- summary: What's happened in the conversation
|
||||
- goal: User's objective for this session
|
||||
- plan: Steps to achieve the goal
|
||||
- progress: Completed steps
|
||||
"""
|
||||
session_id: str = ""
|
||||
summary: str = ""
|
||||
goal: str = ""
|
||||
plan: List[str] = field(default_factory=list)
|
||||
progress: List[str] = field(default_factory=list)
|
||||
|
||||
# Metadata
|
||||
created_at: str = ""
|
||||
updated_at: str = ""
|
||||
turn_count: int = 0
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "SessionState":
|
||||
"""Create from dictionary."""
|
||||
return cls(
|
||||
session_id=data.get("session_id", ""),
|
||||
summary=data.get("summary", ""),
|
||||
goal=data.get("goal", ""),
|
||||
plan=data.get("plan", []),
|
||||
progress=data.get("progress", []),
|
||||
created_at=data.get("created_at", ""),
|
||||
updated_at=data.get("updated_at", ""),
|
||||
turn_count=data.get("turn_count", 0),
|
||||
)
|
||||
|
||||
|
||||
class SessionContextTracker:
|
||||
"""
|
||||
Tracks session context (summary/goal/plan/progress) across turns.
|
||||
|
||||
Implements the Agno SessionContextStore pattern for PraisonAI.
|
||||
Builds upon previous context rather than starting fresh each turn.
|
||||
|
||||
Example:
|
||||
tracker = SessionContextTracker(session_id="abc123")
|
||||
|
||||
# After each turn, update state
|
||||
tracker.update_from_turn(messages, llm_client)
|
||||
|
||||
# Get context string for system prompt
|
||||
context_str = tracker.to_context_string()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_id: str = "",
|
||||
track_summary: bool = True,
|
||||
track_goal: bool = True,
|
||||
track_plan: bool = True,
|
||||
track_progress: bool = True,
|
||||
):
|
||||
"""
|
||||
Initialize session context tracker.
|
||||
|
||||
Args:
|
||||
session_id: Session identifier
|
||||
track_summary: Whether to track conversation summary
|
||||
track_goal: Whether to track user's objective
|
||||
track_plan: Whether to track steps to goal
|
||||
track_progress: Whether to track completed steps
|
||||
"""
|
||||
self.session_id = session_id or self._generate_session_id()
|
||||
self.track_summary = track_summary
|
||||
self.track_goal = track_goal
|
||||
self.track_plan = track_plan
|
||||
self.track_progress = track_progress
|
||||
|
||||
self._state = SessionState(
|
||||
session_id=self.session_id,
|
||||
created_at=datetime.utcnow().isoformat() + "Z",
|
||||
updated_at=datetime.utcnow().isoformat() + "Z",
|
||||
)
|
||||
|
||||
def _generate_session_id(self) -> str:
|
||||
"""Generate a unique session ID."""
|
||||
import uuid
|
||||
return str(uuid.uuid4())[:8]
|
||||
|
||||
@property
|
||||
def state(self) -> SessionState:
|
||||
"""Get current session state."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def summary(self) -> str:
|
||||
"""Get current summary."""
|
||||
return self._state.summary
|
||||
|
||||
@property
|
||||
def goal(self) -> str:
|
||||
"""Get current goal."""
|
||||
return self._state.goal
|
||||
|
||||
@property
|
||||
def plan(self) -> List[str]:
|
||||
"""Get current plan."""
|
||||
return self._state.plan
|
||||
|
||||
@property
|
||||
def progress(self) -> List[str]:
|
||||
"""Get completed steps."""
|
||||
return self._state.progress
|
||||
|
||||
def update_summary(self, summary: str) -> None:
|
||||
"""Update conversation summary."""
|
||||
if self.track_summary:
|
||||
self._state.summary = summary
|
||||
self._mark_updated()
|
||||
|
||||
def update_goal(self, goal: str) -> None:
|
||||
"""Update user's objective."""
|
||||
if self.track_goal:
|
||||
self._state.goal = goal
|
||||
self._mark_updated()
|
||||
|
||||
def update_plan(self, plan: List[str]) -> None:
|
||||
"""Update plan steps."""
|
||||
if self.track_plan:
|
||||
self._state.plan = plan
|
||||
self._mark_updated()
|
||||
|
||||
def add_progress(self, step: str) -> None:
|
||||
"""Add a completed step to progress."""
|
||||
if self.track_progress:
|
||||
self._state.progress.append(step)
|
||||
self._mark_updated()
|
||||
|
||||
def mark_plan_step_complete(self, step_index: int) -> None:
|
||||
"""Mark a plan step as complete and add to progress."""
|
||||
if 0 <= step_index < len(self._state.plan):
|
||||
step = self._state.plan[step_index]
|
||||
self.add_progress(step)
|
||||
|
||||
def _mark_updated(self) -> None:
|
||||
"""Update the updated_at timestamp."""
|
||||
self._state.updated_at = datetime.utcnow().isoformat() + "Z"
|
||||
self._state.turn_count += 1
|
||||
|
||||
def to_context_string(self) -> str:
|
||||
"""
|
||||
Format session context for injection into system prompt.
|
||||
|
||||
Returns string formatted for the agent to use as context.
|
||||
"""
|
||||
parts = []
|
||||
|
||||
if self._state.summary:
|
||||
parts.append(f"**Conversation Summary**: {self._state.summary}")
|
||||
|
||||
if self._state.goal:
|
||||
parts.append(f"**User's Goal**: {self._state.goal}")
|
||||
|
||||
if self._state.plan:
|
||||
plan_str = "\n".join(f" {i+1}. {step}" for i, step in enumerate(self._state.plan))
|
||||
parts.append(f"**Plan**:\n{plan_str}")
|
||||
|
||||
if self._state.progress:
|
||||
progress_str = "\n".join(f" ✓ {step}" for step in self._state.progress)
|
||||
parts.append(f"**Progress**:\n{progress_str}")
|
||||
|
||||
if not parts:
|
||||
return ""
|
||||
|
||||
return "\n\n".join(parts)
|
||||
|
||||
def to_system_prompt_section(self) -> str:
|
||||
"""
|
||||
Generate a system prompt section with session context.
|
||||
|
||||
Follows Agno's pattern of including guidelines for using the context.
|
||||
"""
|
||||
context = self.to_context_string()
|
||||
|
||||
if not context:
|
||||
return ""
|
||||
|
||||
return f"""<session_context>
|
||||
This is a continuation of an ongoing session. Here's where things stand:
|
||||
|
||||
{context}
|
||||
|
||||
<session_context_guidelines>
|
||||
Use this context to maintain continuity:
|
||||
- Reference earlier decisions and conclusions naturally
|
||||
- Don't re-ask questions that have already been answered
|
||||
- Build on established understanding rather than starting fresh
|
||||
- If the user references something from "earlier," this context has the details
|
||||
|
||||
Current messages take precedence if there's any conflict with this summary.
|
||||
</session_context_guidelines>
|
||||
</session_context>"""
|
||||
|
||||
def update_from_messages(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
extract_goal: bool = True,
|
||||
) -> None:
|
||||
"""
|
||||
Update session state from conversation messages.
|
||||
|
||||
This is a simple heuristic-based extraction. For more accurate
|
||||
extraction, use update_from_turn with an LLM.
|
||||
|
||||
Args:
|
||||
messages: List of conversation messages
|
||||
extract_goal: Whether to try extracting goal from first user message
|
||||
"""
|
||||
if not messages:
|
||||
return
|
||||
|
||||
# Extract goal from first user message if not set
|
||||
if extract_goal and not self._state.goal:
|
||||
for msg in messages:
|
||||
if msg.get("role") == "user":
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, str) and len(content) < 500:
|
||||
self._state.goal = content[:200]
|
||||
break
|
||||
|
||||
# Update turn count
|
||||
user_turns = sum(1 for m in messages if m.get("role") == "user")
|
||||
self._state.turn_count = user_turns
|
||||
self._mark_updated()
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear session state."""
|
||||
self._state = SessionState(
|
||||
session_id=self.session_id,
|
||||
created_at=self._state.created_at,
|
||||
updated_at=datetime.utcnow().isoformat() + "Z",
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
"session_id": self.session_id,
|
||||
"track_summary": self.track_summary,
|
||||
"track_goal": self.track_goal,
|
||||
"track_plan": self.track_plan,
|
||||
"track_progress": self.track_progress,
|
||||
"state": self._state.to_dict(),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "SessionContextTracker":
|
||||
"""Create from dictionary."""
|
||||
tracker = cls(
|
||||
session_id=data.get("session_id", ""),
|
||||
track_summary=data.get("track_summary", True),
|
||||
track_goal=data.get("track_goal", True),
|
||||
track_plan=data.get("track_plan", True),
|
||||
track_progress=data.get("track_progress", True),
|
||||
)
|
||||
if "state" in data:
|
||||
tracker._state = SessionState.from_dict(data["state"])
|
||||
return tracker
|
||||
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Embedding module for PraisonAI Agents.
|
||||
|
||||
This module provides a unified embedding API that can be used throughout
|
||||
the praisonaiagents package. It consolidates embedding functionality that
|
||||
was previously duplicated in memory.py and knowledge.py.
|
||||
|
||||
Usage:
|
||||
>>> from praisonaiagents import embedding
|
||||
>>> result = embedding("Hello world")
|
||||
>>> print(result.embeddings[0][:5])
|
||||
|
||||
>>> from praisonaiagents.embedding import embedding, EmbeddingResult
|
||||
>>> result = embedding(["Hello", "World"])
|
||||
>>> print(len(result))
|
||||
2
|
||||
|
||||
The module uses lazy imports to avoid loading litellm until actually needed,
|
||||
ensuring zero performance impact on import time.
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
'embedding',
|
||||
'aembedding',
|
||||
'embed',
|
||||
'aembed',
|
||||
'EmbeddingResult',
|
||||
'get_dimensions',
|
||||
'MODEL_DIMENSIONS',
|
||||
'DEFAULT_DIMENSION',
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy load module components to avoid importing litellm on module load."""
|
||||
if name == 'EmbeddingResult':
|
||||
from .result import EmbeddingResult
|
||||
return EmbeddingResult
|
||||
|
||||
if name in ('embedding', 'embed'):
|
||||
from .embed import embedding
|
||||
return embedding
|
||||
|
||||
if name in ('aembedding', 'aembed'):
|
||||
from .embed import aembedding
|
||||
return aembedding
|
||||
|
||||
if name == 'get_dimensions':
|
||||
from .dimensions import get_dimensions
|
||||
return get_dimensions
|
||||
|
||||
if name == 'MODEL_DIMENSIONS':
|
||||
from .dimensions import MODEL_DIMENSIONS
|
||||
return MODEL_DIMENSIONS
|
||||
|
||||
if name == 'DEFAULT_DIMENSION':
|
||||
from .dimensions import DEFAULT_DIMENSION
|
||||
return DEFAULT_DIMENSION
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
def __dir__():
|
||||
"""Return list of public names for dir()."""
|
||||
return list(__all__)
|
||||
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
Embedding dimension utilities.
|
||||
|
||||
This module provides utilities for determining embedding dimensions
|
||||
based on model names. Consolidates the dimension lookup logic that
|
||||
was previously duplicated in memory.py and knowledge.py.
|
||||
"""
|
||||
|
||||
from typing import Dict
|
||||
|
||||
# Common embedding model dimensions
|
||||
# This is the single source of truth for dimension lookup
|
||||
MODEL_DIMENSIONS: Dict[str, int] = {
|
||||
"text-embedding-3-small": 1536,
|
||||
"text-embedding-3-large": 3072,
|
||||
"text-embedding-ada-002": 1536,
|
||||
"text-embedding-002": 1536,
|
||||
# Cohere models
|
||||
"embed-english-v3.0": 1024,
|
||||
"embed-multilingual-v3.0": 1024,
|
||||
"embed-english-light-v3.0": 384,
|
||||
"embed-multilingual-light-v3.0": 384,
|
||||
# Voyage models
|
||||
"voyage-3": 1024,
|
||||
"voyage-3-lite": 512,
|
||||
"voyage-code-3": 1024,
|
||||
# Mistral models
|
||||
"mistral-embed": 1024,
|
||||
# Jina models
|
||||
"jina-embeddings-v3": 1024,
|
||||
"jina-embeddings-v2-base-en": 768,
|
||||
# HuggingFace common models
|
||||
"all-MiniLM-L6-v2": 384,
|
||||
"all-mpnet-base-v2": 768,
|
||||
"bge-large-en-v1.5": 1024,
|
||||
"bge-base-en-v1.5": 768,
|
||||
"bge-small-en-v1.5": 384,
|
||||
}
|
||||
|
||||
# Default dimension for unknown models
|
||||
DEFAULT_DIMENSION = 1536
|
||||
|
||||
|
||||
def get_dimensions(model_name: str) -> int:
|
||||
"""Get embedding dimensions based on model name.
|
||||
|
||||
This function checks if the model name contains any known model
|
||||
identifiers and returns the corresponding dimension. Falls back
|
||||
to the default dimension (1536) for unknown models.
|
||||
|
||||
Args:
|
||||
model_name: The embedding model name (e.g., "text-embedding-3-small")
|
||||
|
||||
Returns:
|
||||
The embedding dimension for the model
|
||||
|
||||
Example:
|
||||
>>> get_dimensions("text-embedding-3-small")
|
||||
1536
|
||||
>>> get_dimensions("openai/text-embedding-3-large")
|
||||
3072
|
||||
>>> get_dimensions("unknown-model")
|
||||
1536
|
||||
"""
|
||||
model_lower = model_name.lower()
|
||||
|
||||
# Check for exact match first
|
||||
if model_lower in MODEL_DIMENSIONS:
|
||||
return MODEL_DIMENSIONS[model_lower]
|
||||
|
||||
# Check if model name contains known model identifiers
|
||||
for model_key, dimensions in MODEL_DIMENSIONS.items():
|
||||
if model_key in model_lower:
|
||||
return dimensions
|
||||
|
||||
# Default to 1536 for unknown models (OpenAI standard)
|
||||
return DEFAULT_DIMENSION
|
||||
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
Core embedding functions.
|
||||
|
||||
This module provides the main embedding() and aembedding() functions
|
||||
that wrap LiteLLM's embedding API with a consistent interface.
|
||||
"""
|
||||
|
||||
from typing import Optional, Union, List, Any, Dict
|
||||
|
||||
from .result import EmbeddingResult
|
||||
|
||||
|
||||
def embedding(
|
||||
input: Union[str, List[str]],
|
||||
model: str = "text-embedding-3-small",
|
||||
dimensions: Optional[int] = None,
|
||||
encoding_format: str = "float",
|
||||
timeout: float = 600.0,
|
||||
api_key: Optional[str] = None,
|
||||
api_base: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
**kwargs
|
||||
) -> EmbeddingResult:
|
||||
"""
|
||||
Generate embeddings for text using LiteLLM.
|
||||
|
||||
This is the primary embedding function that supports all LiteLLM
|
||||
embedding providers (OpenAI, Azure, Cohere, Voyage, etc.).
|
||||
|
||||
Args:
|
||||
input: Text or list of texts to embed
|
||||
model: Model name (e.g., "text-embedding-3-small", "text-embedding-3-large")
|
||||
dimensions: Optional output dimensions (for models that support it)
|
||||
encoding_format: "float" or "base64"
|
||||
timeout: Request timeout in seconds
|
||||
api_key: Optional API key override
|
||||
api_base: Optional API base URL override
|
||||
metadata: Optional metadata for tracing
|
||||
**kwargs: Additional arguments passed to litellm.embedding()
|
||||
|
||||
Returns:
|
||||
EmbeddingResult with embeddings list, model, usage, and metadata
|
||||
|
||||
Example:
|
||||
>>> from praisonaiagents import embedding
|
||||
>>> result = embedding("Hello, world!")
|
||||
>>> print(len(result.embeddings[0]))
|
||||
1536
|
||||
|
||||
>>> result = embedding(["Hello", "World"], model="text-embedding-3-large")
|
||||
>>> print(len(result.embeddings))
|
||||
2
|
||||
"""
|
||||
import litellm
|
||||
|
||||
call_kwargs = {
|
||||
'model': model,
|
||||
'input': input,
|
||||
'encoding_format': encoding_format,
|
||||
'timeout': timeout,
|
||||
}
|
||||
|
||||
if dimensions:
|
||||
call_kwargs['dimensions'] = dimensions
|
||||
if api_key:
|
||||
call_kwargs['api_key'] = api_key
|
||||
if api_base:
|
||||
call_kwargs['api_base'] = api_base
|
||||
|
||||
call_kwargs.update(kwargs)
|
||||
|
||||
if metadata:
|
||||
call_kwargs['metadata'] = metadata
|
||||
|
||||
response = litellm.embedding(**call_kwargs)
|
||||
|
||||
embeddings = []
|
||||
if hasattr(response, 'data'):
|
||||
for item in response.data:
|
||||
if isinstance(item, dict):
|
||||
embeddings.append(item.get('embedding', []))
|
||||
else:
|
||||
embeddings.append(getattr(item, 'embedding', []))
|
||||
|
||||
usage = None
|
||||
if hasattr(response, 'usage'):
|
||||
usage = {
|
||||
'prompt_tokens': getattr(response.usage, 'prompt_tokens', 0),
|
||||
'total_tokens': getattr(response.usage, 'total_tokens', 0),
|
||||
}
|
||||
|
||||
return EmbeddingResult(
|
||||
embeddings=embeddings,
|
||||
model=model,
|
||||
usage=usage,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
|
||||
|
||||
async def aembedding(
|
||||
input: Union[str, List[str]],
|
||||
model: str = "text-embedding-3-small",
|
||||
dimensions: Optional[int] = None,
|
||||
encoding_format: str = "float",
|
||||
timeout: float = 600.0,
|
||||
api_key: Optional[str] = None,
|
||||
api_base: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
**kwargs
|
||||
) -> EmbeddingResult:
|
||||
"""
|
||||
Async: Generate embeddings for text using LiteLLM.
|
||||
|
||||
This is the async version of embedding() for use in async contexts.
|
||||
See embedding() for full documentation.
|
||||
|
||||
Example:
|
||||
>>> from praisonaiagents import aembedding
|
||||
>>> result = await aembedding("Hello, world!")
|
||||
>>> print(len(result.embeddings[0]))
|
||||
1536
|
||||
"""
|
||||
import litellm
|
||||
|
||||
call_kwargs = {
|
||||
'model': model,
|
||||
'input': input,
|
||||
'encoding_format': encoding_format,
|
||||
'timeout': timeout,
|
||||
}
|
||||
|
||||
if dimensions:
|
||||
call_kwargs['dimensions'] = dimensions
|
||||
if api_key:
|
||||
call_kwargs['api_key'] = api_key
|
||||
if api_base:
|
||||
call_kwargs['api_base'] = api_base
|
||||
|
||||
call_kwargs.update(kwargs)
|
||||
|
||||
if metadata:
|
||||
call_kwargs['metadata'] = metadata
|
||||
|
||||
response = await litellm.aembedding(**call_kwargs)
|
||||
|
||||
embeddings = []
|
||||
if hasattr(response, 'data'):
|
||||
for item in response.data:
|
||||
if isinstance(item, dict):
|
||||
embeddings.append(item.get('embedding', []))
|
||||
else:
|
||||
embeddings.append(getattr(item, 'embedding', []))
|
||||
|
||||
usage = None
|
||||
if hasattr(response, 'usage'):
|
||||
usage = {
|
||||
'prompt_tokens': getattr(response.usage, 'prompt_tokens', 0),
|
||||
'total_tokens': getattr(response.usage, 'total_tokens', 0),
|
||||
}
|
||||
|
||||
return EmbeddingResult(
|
||||
embeddings=embeddings,
|
||||
model=model,
|
||||
usage=usage,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
|
||||
|
||||
# Aliases for backwards compatibility and OpenAI naming convention
|
||||
embed = embedding
|
||||
"""Alias for embedding(). Use embedding() or embed() interchangeably."""
|
||||
|
||||
aembed = aembedding
|
||||
"""Alias for aembedding(). Use aembedding() or aembed() interchangeably."""
|
||||
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
EmbeddingResult dataclass for embedding responses.
|
||||
|
||||
This module provides a structured result type for embedding operations,
|
||||
matching the OpenAI embedding response format.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmbeddingResult:
|
||||
"""Result from embedding generation.
|
||||
|
||||
Attributes:
|
||||
embeddings: List of embedding vectors (each is a list of floats)
|
||||
model: The model used for embedding (optional)
|
||||
usage: Token usage information (optional)
|
||||
metadata: Additional metadata (optional)
|
||||
|
||||
Example:
|
||||
>>> result = EmbeddingResult(
|
||||
... embeddings=[[0.1, 0.2, 0.3]],
|
||||
... model="text-embedding-3-small",
|
||||
... usage={"prompt_tokens": 5, "total_tokens": 5}
|
||||
... )
|
||||
>>> print(len(result.embeddings[0]))
|
||||
3
|
||||
"""
|
||||
embeddings: List[List[float]]
|
||||
model: Optional[str] = None
|
||||
usage: Optional[Dict[str, int]] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Return the number of embeddings."""
|
||||
return len(self.embeddings)
|
||||
|
||||
def __getitem__(self, index: int) -> List[float]:
|
||||
"""Get embedding by index."""
|
||||
return self.embeddings[index]
|
||||
@@ -12,6 +12,9 @@ class Chunking:
|
||||
'semantic': ['chunk_size', 'embedding_model'],
|
||||
'sdpm': ['chunk_size', 'embedding_model'],
|
||||
'late': ['chunk_size', 'embedding_model'],
|
||||
'code': ['chunk_size', 'tokenizer_or_token_counter'],
|
||||
'neural': ['chunk_size', 'tokenizer_or_token_counter'],
|
||||
'slumber': ['chunk_size', 'genie'],
|
||||
}
|
||||
|
||||
@cached_property
|
||||
@@ -24,7 +27,10 @@ class Chunking:
|
||||
SemanticChunker,
|
||||
SDPMChunker,
|
||||
LateChunker,
|
||||
RecursiveChunker
|
||||
RecursiveChunker,
|
||||
CodeChunker,
|
||||
NeuralChunker,
|
||||
SlumberChunker
|
||||
)
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
@@ -37,7 +43,10 @@ class Chunking:
|
||||
'semantic': SemanticChunker,
|
||||
'sdpm': SDPMChunker,
|
||||
'late': LateChunker,
|
||||
'recursive': RecursiveChunker
|
||||
'recursive': RecursiveChunker,
|
||||
'code': CodeChunker,
|
||||
'neural': NeuralChunker,
|
||||
'slumber': SlumberChunker
|
||||
}
|
||||
|
||||
def __init__(
|
||||
|
||||
@@ -110,22 +110,8 @@ class MongoDBMemory:
|
||||
|
||||
def _get_embedding_dimensions(self, model_name: str) -> int:
|
||||
"""Get embedding dimensions based on model name."""
|
||||
# Common embedding model dimensions
|
||||
model_dimensions = {
|
||||
"text-embedding-3-small": 1536,
|
||||
"text-embedding-3-large": 3072,
|
||||
"text-embedding-ada-002": 1536,
|
||||
"text-embedding-002": 1536,
|
||||
# Add more models as needed
|
||||
}
|
||||
|
||||
# Check if model name contains known model identifiers
|
||||
for model_key, dimensions in model_dimensions.items():
|
||||
if model_key in model_name.lower():
|
||||
return dimensions
|
||||
|
||||
# Default to 1536 for unknown models (OpenAI standard)
|
||||
return 1536
|
||||
from praisonaiagents.embedding import get_dimensions
|
||||
return get_dimensions(model_name)
|
||||
|
||||
def _create_indexes(self):
|
||||
"""Create necessary indexes for MongoDB."""
|
||||
@@ -173,16 +159,11 @@ class MongoDBMemory:
|
||||
logging.warning(f"Could not create vector search index: {e}")
|
||||
|
||||
def _get_embedding(self, text):
|
||||
"""Get embedding for text using litellm for unified provider support."""
|
||||
"""Get embedding for text using the unified embedding module."""
|
||||
try:
|
||||
import litellm
|
||||
|
||||
# Use litellm.embedding() for unified multi-provider support
|
||||
response = litellm.embedding(
|
||||
model=self.embedding_model_name,
|
||||
input=text
|
||||
)
|
||||
return response.data[0]["embedding"]
|
||||
from praisonaiagents.embedding import embedding
|
||||
result = embedding(text, model=self.embedding_model_name)
|
||||
return result.embeddings[0] if result.embeddings else None
|
||||
except Exception as e:
|
||||
logging.error(f"Error getting embedding: {e}")
|
||||
return None
|
||||
|
||||
@@ -1210,8 +1210,13 @@ Now provide your final answer using this result."""
|
||||
# Only append JSON schema for non-Gemini models or Gemini models without structured output support
|
||||
if (output_json or output_pydantic) and not is_gemini_with_structured_output:
|
||||
schema_model = output_json or output_pydantic
|
||||
if schema_model and hasattr(schema_model, 'model_json_schema'):
|
||||
system_prompt += f"\nReturn ONLY a JSON object that matches this Pydantic model: {json.dumps(schema_model.model_json_schema())}"
|
||||
if schema_model:
|
||||
# Handle Pydantic model
|
||||
if hasattr(schema_model, 'model_json_schema'):
|
||||
system_prompt += f"\nReturn ONLY a JSON object that matches this Pydantic model: {json.dumps(schema_model.model_json_schema())}"
|
||||
# Handle inline dict schema (Option A from YAML)
|
||||
elif isinstance(schema_model, dict):
|
||||
system_prompt += f"\nReturn ONLY a JSON object that matches this schema: {json.dumps(schema_model)}"
|
||||
|
||||
# Skip system messages for legacy o1 models as they don't support them
|
||||
if not self._needs_system_message_skip():
|
||||
@@ -3771,20 +3776,46 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
params.pop(param, None)
|
||||
|
||||
if output_json or output_pydantic:
|
||||
from .model_capabilities import supports_structured_outputs
|
||||
schema_model = output_json or output_pydantic
|
||||
|
||||
# Check if this is a Gemini model that supports native structured outputs
|
||||
if self._is_gemini_model():
|
||||
from .model_capabilities import supports_structured_outputs
|
||||
schema_model = output_json or output_pydantic
|
||||
|
||||
if schema_model and hasattr(schema_model, 'model_json_schema') and supports_structured_outputs(self.model):
|
||||
schema = schema_model.model_json_schema()
|
||||
|
||||
# Gemini uses response_mime_type and response_schema
|
||||
params['response_mime_type'] = 'application/json'
|
||||
params['response_schema'] = schema
|
||||
|
||||
logging.debug(f"Using Gemini native structured output with schema: {json.dumps(schema, indent=2)}")
|
||||
# Check if model supports structured outputs
|
||||
if supports_structured_outputs(self.model):
|
||||
# Check if this is a Gemini model (uses different params)
|
||||
if self._is_gemini_model():
|
||||
if schema_model and hasattr(schema_model, 'model_json_schema'):
|
||||
schema = schema_model.model_json_schema()
|
||||
# Gemini uses response_mime_type and response_schema
|
||||
params['response_mime_type'] = 'application/json'
|
||||
params['response_schema'] = schema
|
||||
logging.debug(f"Using Gemini native structured output with schema: {json.dumps(schema, indent=2)}")
|
||||
else:
|
||||
# OpenAI and other models use response_format
|
||||
# Handle Pydantic model
|
||||
if schema_model and hasattr(schema_model, 'model_json_schema'):
|
||||
schema = schema_model.model_json_schema()
|
||||
# Use json_schema format for strict validation
|
||||
params['response_format'] = {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": getattr(schema_model, '__name__', 'response'),
|
||||
"schema": schema,
|
||||
"strict": True
|
||||
}
|
||||
}
|
||||
logging.debug(f"Using OpenAI structured output with schema: {json.dumps(schema, indent=2)}")
|
||||
# Handle dict schema (inline JSON schema from YAML)
|
||||
elif isinstance(schema_model, dict):
|
||||
# Inline JSON schema - wrap in response_format
|
||||
params['response_format'] = {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "structured_output",
|
||||
"schema": schema_model,
|
||||
"strict": True
|
||||
}
|
||||
}
|
||||
logging.debug(f"Using inline JSON schema: {json.dumps(schema_model, indent=2)}")
|
||||
|
||||
# Add tool_choice="auto" when tools are provided (unless already specified)
|
||||
# This encourages the model to use tools when appropriate
|
||||
|
||||
@@ -342,9 +342,19 @@ class OpenAIClient:
|
||||
if system_prompt:
|
||||
# Append JSON schema if needed
|
||||
if output_json:
|
||||
system_prompt += f"\nReturn ONLY a JSON object that matches this Pydantic model: {json.dumps(output_json.model_json_schema())}"
|
||||
# Handle Pydantic model
|
||||
if hasattr(output_json, 'model_json_schema'):
|
||||
system_prompt += f"\nReturn ONLY a JSON object that matches this Pydantic model: {json.dumps(output_json.model_json_schema())}"
|
||||
# Handle inline dict schema (Option A from YAML)
|
||||
elif isinstance(output_json, dict):
|
||||
system_prompt += f"\nReturn ONLY a JSON object that matches this schema: {json.dumps(output_json)}"
|
||||
elif output_pydantic:
|
||||
system_prompt += f"\nReturn ONLY a JSON object that matches this Pydantic model: {json.dumps(output_pydantic.model_json_schema())}"
|
||||
# Handle Pydantic model
|
||||
if hasattr(output_pydantic, 'model_json_schema'):
|
||||
system_prompt += f"\nReturn ONLY a JSON object that matches this Pydantic model: {json.dumps(output_pydantic.model_json_schema())}"
|
||||
# Handle inline dict schema
|
||||
elif isinstance(output_pydantic, dict):
|
||||
system_prompt += f"\nReturn ONLY a JSON object that matches this schema: {json.dumps(output_pydantic)}"
|
||||
|
||||
messages.append({"role": "system", "content": system_prompt})
|
||||
|
||||
|
||||
@@ -54,6 +54,35 @@ def __getattr__(name):
|
||||
elif name == "get_thread_local_event_loop":
|
||||
from .mcp_schema_utils import get_thread_local_event_loop
|
||||
return get_thread_local_event_loop
|
||||
# Auth storage (lazy loaded)
|
||||
elif name == "MCPAuthStorage":
|
||||
from .mcp_auth_storage import MCPAuthStorage
|
||||
return MCPAuthStorage
|
||||
elif name == "get_default_auth_filepath":
|
||||
from .mcp_auth_storage import get_default_auth_filepath
|
||||
return get_default_auth_filepath
|
||||
# OAuth callback utilities (lazy loaded)
|
||||
elif name == "OAuthCallbackHandler":
|
||||
from .mcp_oauth_callback import OAuthCallbackHandler
|
||||
return OAuthCallbackHandler
|
||||
elif name == "generate_state":
|
||||
from .mcp_oauth_callback import generate_state
|
||||
return generate_state
|
||||
elif name == "generate_code_verifier":
|
||||
from .mcp_oauth_callback import generate_code_verifier
|
||||
return generate_code_verifier
|
||||
elif name == "generate_code_challenge":
|
||||
from .mcp_oauth_callback import generate_code_challenge
|
||||
return generate_code_challenge
|
||||
elif name == "get_redirect_url":
|
||||
from .mcp_oauth_callback import get_redirect_url
|
||||
return get_redirect_url
|
||||
elif name == "OAUTH_CALLBACK_PORT":
|
||||
from .mcp_oauth_callback import OAUTH_CALLBACK_PORT
|
||||
return OAUTH_CALLBACK_PORT
|
||||
elif name == "OAUTH_CALLBACK_PATH":
|
||||
from .mcp_oauth_callback import OAUTH_CALLBACK_PATH
|
||||
return OAUTH_CALLBACK_PATH
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
__all__ = [
|
||||
@@ -76,4 +105,15 @@ __all__ = [
|
||||
"SecurityConfig",
|
||||
"BaseTransport",
|
||||
"TransportConfig",
|
||||
# Auth storage (lazy loaded)
|
||||
"MCPAuthStorage",
|
||||
"get_default_auth_filepath",
|
||||
# OAuth callback utilities (lazy loaded)
|
||||
"OAuthCallbackHandler",
|
||||
"generate_state",
|
||||
"generate_code_verifier",
|
||||
"generate_code_challenge",
|
||||
"get_redirect_url",
|
||||
"OAUTH_CALLBACK_PORT",
|
||||
"OAUTH_CALLBACK_PATH",
|
||||
]
|
||||
@@ -0,0 +1,294 @@
|
||||
"""
|
||||
MCP Authentication Storage.
|
||||
|
||||
This module provides persistent storage for OAuth tokens and client information
|
||||
for MCP servers. It handles:
|
||||
- Access/refresh token storage
|
||||
- Client registration info (for dynamic registration)
|
||||
- PKCE code verifiers
|
||||
- OAuth state for CSRF protection
|
||||
|
||||
Storage is file-based with restricted permissions (0600) for security.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
def get_default_auth_filepath() -> str:
|
||||
"""
|
||||
Get the default filepath for MCP auth storage.
|
||||
|
||||
Returns:
|
||||
Path to mcp-auth.json in user's praison config directory
|
||||
"""
|
||||
# Use ~/.praison/ directory for config
|
||||
home = Path.home()
|
||||
praison_dir = home / ".praison"
|
||||
praison_dir.mkdir(parents=True, exist_ok=True)
|
||||
return str(praison_dir / "mcp-auth.json")
|
||||
|
||||
|
||||
class MCPAuthStorage:
|
||||
"""
|
||||
Persistent storage for MCP OAuth credentials.
|
||||
|
||||
This class manages storage of OAuth tokens, client information,
|
||||
and temporary OAuth flow data (code verifiers, state) for MCP servers.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from praisonaiagents.mcp.mcp_auth_storage import MCPAuthStorage
|
||||
|
||||
storage = MCPAuthStorage()
|
||||
|
||||
# Store tokens after OAuth flow
|
||||
storage.set_tokens("github", {
|
||||
"access_token": "gho_xxx",
|
||||
"refresh_token": "ghr_xxx",
|
||||
"expires_at": 1234567890
|
||||
}, server_url="https://api.github.com/mcp")
|
||||
|
||||
# Check if authenticated
|
||||
entry = storage.get("github")
|
||||
if entry and entry.get("tokens"):
|
||||
print("Authenticated!")
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, filepath: Optional[str] = None):
|
||||
"""
|
||||
Initialize auth storage.
|
||||
|
||||
Args:
|
||||
filepath: Path to auth storage file. Defaults to ~/.praison/mcp-auth.json
|
||||
"""
|
||||
self.filepath = filepath or get_default_auth_filepath()
|
||||
self._ensure_directory()
|
||||
|
||||
def _ensure_directory(self) -> None:
|
||||
"""Ensure the parent directory exists."""
|
||||
parent = Path(self.filepath).parent
|
||||
parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _read(self) -> Dict[str, Any]:
|
||||
"""Read all entries from storage file."""
|
||||
try:
|
||||
with open(self.filepath, 'r') as f:
|
||||
return json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
def _write(self, data: Dict[str, Any]) -> None:
|
||||
"""Write all entries to storage file with secure permissions."""
|
||||
# Write to file
|
||||
with open(self.filepath, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
# Set file permissions to 0600 (owner read/write only)
|
||||
if os.name != 'nt': # Unix-like systems
|
||||
os.chmod(self.filepath, 0o600)
|
||||
|
||||
def get(self, mcp_name: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get auth entry for an MCP server.
|
||||
|
||||
Args:
|
||||
mcp_name: Name of the MCP server
|
||||
|
||||
Returns:
|
||||
Auth entry dict or None if not found
|
||||
"""
|
||||
data = self._read()
|
||||
return data.get(mcp_name)
|
||||
|
||||
def get_for_url(self, mcp_name: str, server_url: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get auth entry and validate it's for the correct URL.
|
||||
|
||||
Returns None if URL has changed (credentials are invalid).
|
||||
|
||||
Args:
|
||||
mcp_name: Name of the MCP server
|
||||
server_url: Expected server URL
|
||||
|
||||
Returns:
|
||||
Auth entry if URL matches, None otherwise
|
||||
"""
|
||||
entry = self.get(mcp_name)
|
||||
if not entry:
|
||||
return None
|
||||
|
||||
# If no server_url stored, consider it invalid (old version)
|
||||
if not entry.get("server_url"):
|
||||
return None
|
||||
|
||||
# If URL changed, credentials are invalid
|
||||
if entry.get("server_url") != server_url:
|
||||
return None
|
||||
|
||||
return entry
|
||||
|
||||
def all(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Get all stored auth entries.
|
||||
|
||||
Returns:
|
||||
Dict mapping MCP server names to their auth entries
|
||||
"""
|
||||
return self._read()
|
||||
|
||||
def _set(self, mcp_name: str, entry: Dict[str, Any], server_url: Optional[str] = None) -> None:
|
||||
"""
|
||||
Set an auth entry for an MCP server.
|
||||
|
||||
Args:
|
||||
mcp_name: Name of the MCP server
|
||||
entry: Auth entry dict
|
||||
server_url: Optional server URL to track
|
||||
"""
|
||||
data = self._read()
|
||||
|
||||
# Update server_url if provided
|
||||
if server_url:
|
||||
entry["server_url"] = server_url
|
||||
|
||||
data[mcp_name] = entry
|
||||
self._write(data)
|
||||
|
||||
def set_tokens(
|
||||
self,
|
||||
mcp_name: str,
|
||||
tokens: Dict[str, Any],
|
||||
server_url: Optional[str] = None
|
||||
) -> None:
|
||||
"""
|
||||
Set OAuth tokens for an MCP server.
|
||||
|
||||
Args:
|
||||
mcp_name: Name of the MCP server
|
||||
tokens: Token dict with access_token, refresh_token, expires_at, scope
|
||||
server_url: Server URL these tokens are for
|
||||
"""
|
||||
entry = self.get(mcp_name) or {}
|
||||
entry["tokens"] = tokens
|
||||
self._set(mcp_name, entry, server_url)
|
||||
|
||||
def set_client_info(
|
||||
self,
|
||||
mcp_name: str,
|
||||
client_info: Dict[str, Any],
|
||||
server_url: Optional[str] = None
|
||||
) -> None:
|
||||
"""
|
||||
Set client info from dynamic registration.
|
||||
|
||||
Args:
|
||||
mcp_name: Name of the MCP server
|
||||
client_info: Client info dict with client_id, client_secret, etc.
|
||||
server_url: Server URL this client is registered with
|
||||
"""
|
||||
entry = self.get(mcp_name) or {}
|
||||
entry["client_info"] = client_info
|
||||
self._set(mcp_name, entry, server_url)
|
||||
|
||||
def remove(self, mcp_name: str) -> None:
|
||||
"""
|
||||
Remove auth entry for an MCP server.
|
||||
|
||||
Args:
|
||||
mcp_name: Name of the MCP server
|
||||
"""
|
||||
data = self._read()
|
||||
if mcp_name in data:
|
||||
del data[mcp_name]
|
||||
self._write(data)
|
||||
|
||||
def set_code_verifier(self, mcp_name: str, code_verifier: str) -> None:
|
||||
"""
|
||||
Set PKCE code verifier for OAuth flow.
|
||||
|
||||
Args:
|
||||
mcp_name: Name of the MCP server
|
||||
code_verifier: PKCE code verifier string
|
||||
"""
|
||||
entry = self.get(mcp_name) or {}
|
||||
entry["code_verifier"] = code_verifier
|
||||
self._set(mcp_name, entry)
|
||||
|
||||
def clear_code_verifier(self, mcp_name: str) -> None:
|
||||
"""
|
||||
Clear PKCE code verifier after use.
|
||||
|
||||
Args:
|
||||
mcp_name: Name of the MCP server
|
||||
"""
|
||||
entry = self.get(mcp_name)
|
||||
if entry and "code_verifier" in entry:
|
||||
del entry["code_verifier"]
|
||||
self._set(mcp_name, entry)
|
||||
|
||||
def set_oauth_state(self, mcp_name: str, state: str) -> None:
|
||||
"""
|
||||
Set OAuth state for CSRF protection.
|
||||
|
||||
Args:
|
||||
mcp_name: Name of the MCP server
|
||||
state: Random state string
|
||||
"""
|
||||
entry = self.get(mcp_name) or {}
|
||||
entry["oauth_state"] = state
|
||||
self._set(mcp_name, entry)
|
||||
|
||||
def get_oauth_state(self, mcp_name: str) -> Optional[str]:
|
||||
"""
|
||||
Get OAuth state for an MCP server.
|
||||
|
||||
Args:
|
||||
mcp_name: Name of the MCP server
|
||||
|
||||
Returns:
|
||||
OAuth state string or None
|
||||
"""
|
||||
entry = self.get(mcp_name)
|
||||
if entry:
|
||||
return entry.get("oauth_state")
|
||||
return None
|
||||
|
||||
def clear_oauth_state(self, mcp_name: str) -> None:
|
||||
"""
|
||||
Clear OAuth state after use.
|
||||
|
||||
Args:
|
||||
mcp_name: Name of the MCP server
|
||||
"""
|
||||
entry = self.get(mcp_name)
|
||||
if entry and "oauth_state" in entry:
|
||||
del entry["oauth_state"]
|
||||
self._set(mcp_name, entry)
|
||||
|
||||
def is_token_expired(self, mcp_name: str) -> Optional[bool]:
|
||||
"""
|
||||
Check if stored tokens are expired.
|
||||
|
||||
Args:
|
||||
mcp_name: Name of the MCP server
|
||||
|
||||
Returns:
|
||||
None if no tokens exist
|
||||
False if no expiry or not expired
|
||||
True if expired
|
||||
"""
|
||||
entry = self.get(mcp_name)
|
||||
if not entry or not entry.get("tokens"):
|
||||
return None
|
||||
|
||||
tokens = entry["tokens"]
|
||||
expires_at = tokens.get("expires_at")
|
||||
if not expires_at:
|
||||
return False
|
||||
|
||||
return expires_at < time.time()
|
||||
@@ -0,0 +1,256 @@
|
||||
"""
|
||||
MCP OAuth Callback Handler.
|
||||
|
||||
This module provides utilities for handling OAuth 2.1 callback flow for MCP servers.
|
||||
It includes:
|
||||
- Local callback server for receiving authorization codes
|
||||
- PKCE code verifier/challenge generation
|
||||
- OAuth state generation for CSRF protection
|
||||
|
||||
The callback server listens on localhost for the OAuth redirect after
|
||||
browser-based authorization.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import secrets
|
||||
import threading
|
||||
from typing import Dict, Optional
|
||||
|
||||
|
||||
# Default callback configuration (same as OpenCode for consistency)
|
||||
OAUTH_CALLBACK_PORT = 19876
|
||||
OAUTH_CALLBACK_PATH = "/mcp/oauth/callback"
|
||||
|
||||
|
||||
def get_redirect_url(port: int = OAUTH_CALLBACK_PORT) -> str:
|
||||
"""
|
||||
Get the OAuth redirect URL.
|
||||
|
||||
Args:
|
||||
port: Port for the callback server (default: 19876)
|
||||
|
||||
Returns:
|
||||
Full redirect URL for OAuth providers to whitelist
|
||||
"""
|
||||
return f"http://127.0.0.1:{port}{OAUTH_CALLBACK_PATH}"
|
||||
|
||||
|
||||
def generate_state() -> str:
|
||||
"""
|
||||
Generate a random OAuth state parameter for CSRF protection.
|
||||
|
||||
Returns:
|
||||
URL-safe random string
|
||||
"""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def generate_code_verifier() -> str:
|
||||
"""
|
||||
Generate a PKCE code verifier.
|
||||
|
||||
Per RFC 7636, the code verifier is a high-entropy cryptographic
|
||||
random string between 43 and 128 characters.
|
||||
|
||||
Returns:
|
||||
URL-safe random string (43-128 chars)
|
||||
"""
|
||||
# Generate 32 bytes = 43 base64url characters (without padding)
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def generate_code_challenge(code_verifier: str) -> str:
|
||||
"""
|
||||
Generate a PKCE code challenge from a code verifier.
|
||||
|
||||
Uses S256 method: BASE64URL(SHA256(code_verifier))
|
||||
|
||||
Args:
|
||||
code_verifier: The code verifier string
|
||||
|
||||
Returns:
|
||||
Base64url-encoded SHA256 hash (no padding)
|
||||
"""
|
||||
# SHA256 hash of the verifier
|
||||
digest = hashlib.sha256(code_verifier.encode('ascii')).digest()
|
||||
|
||||
# Base64url encode without padding
|
||||
challenge = base64.urlsafe_b64encode(digest).decode('ascii')
|
||||
|
||||
# Remove padding (= characters)
|
||||
return challenge.rstrip('=')
|
||||
|
||||
|
||||
class OAuthCallbackHandler:
|
||||
"""
|
||||
Handler for OAuth callback flow.
|
||||
|
||||
This class manages the state of OAuth callbacks, allowing
|
||||
the application to wait for authorization codes after
|
||||
redirecting the user to the authorization URL.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from praisonaiagents.mcp.mcp_oauth_callback import (
|
||||
OAuthCallbackHandler, generate_state, get_redirect_url
|
||||
)
|
||||
|
||||
handler = OAuthCallbackHandler()
|
||||
state = generate_state()
|
||||
|
||||
# Build authorization URL with state and redirect_uri
|
||||
auth_url = f"{auth_endpoint}?state={state}&redirect_uri={get_redirect_url()}"
|
||||
|
||||
# Open browser for user authorization
|
||||
webbrowser.open(auth_url)
|
||||
|
||||
# Wait for callback (blocks until callback received or timeout)
|
||||
code = handler.wait_for_callback(state, timeout=300)
|
||||
|
||||
# Exchange code for tokens
|
||||
tokens = exchange_code_for_tokens(code)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the callback handler."""
|
||||
self._callbacks: Dict[str, str] = {} # state -> code
|
||||
self._events: Dict[str, threading.Event] = {} # state -> event
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def receive_callback(self, state: str, code: str) -> None:
|
||||
"""
|
||||
Receive an OAuth callback with authorization code.
|
||||
|
||||
Called by the callback server when it receives a redirect.
|
||||
|
||||
Args:
|
||||
state: OAuth state parameter
|
||||
code: Authorization code from the OAuth provider
|
||||
"""
|
||||
with self._lock:
|
||||
self._callbacks[state] = code
|
||||
if state in self._events:
|
||||
self._events[state].set()
|
||||
|
||||
def get_code(self, state: str) -> Optional[str]:
|
||||
"""
|
||||
Get the authorization code for a state.
|
||||
|
||||
Args:
|
||||
state: OAuth state parameter
|
||||
|
||||
Returns:
|
||||
Authorization code or None if not received
|
||||
"""
|
||||
with self._lock:
|
||||
return self._callbacks.get(state)
|
||||
|
||||
def wait_for_callback(self, state: str, timeout: float = 300.0) -> str:
|
||||
"""
|
||||
Wait for an OAuth callback with the given state.
|
||||
|
||||
Blocks until the callback is received or timeout.
|
||||
|
||||
Args:
|
||||
state: OAuth state parameter to wait for
|
||||
timeout: Maximum time to wait in seconds
|
||||
|
||||
Returns:
|
||||
Authorization code
|
||||
|
||||
Raises:
|
||||
TimeoutError: If callback not received within timeout
|
||||
ValueError: If state doesn't match
|
||||
"""
|
||||
# Check if already received
|
||||
code = self.get_code(state)
|
||||
if code:
|
||||
return code
|
||||
|
||||
# Create event for this state
|
||||
with self._lock:
|
||||
if state not in self._events:
|
||||
self._events[state] = threading.Event()
|
||||
event = self._events[state]
|
||||
|
||||
# Wait for callback
|
||||
if not event.wait(timeout=timeout):
|
||||
raise TimeoutError(f"OAuth callback not received within {timeout} seconds")
|
||||
|
||||
# Get the code
|
||||
code = self.get_code(state)
|
||||
if not code:
|
||||
# Check if a different state was received
|
||||
with self._lock:
|
||||
if self._callbacks:
|
||||
received_states = list(self._callbacks.keys())
|
||||
raise ValueError(
|
||||
f"OAuth state mismatch. Expected '{state}', "
|
||||
f"received: {received_states}"
|
||||
)
|
||||
raise ValueError(f"OAuth callback received but no code for state '{state}'")
|
||||
|
||||
return code
|
||||
|
||||
async def async_wait_for_callback(self, state: str, timeout: float = 300.0) -> str:
|
||||
"""
|
||||
Async version of wait_for_callback.
|
||||
|
||||
Args:
|
||||
state: OAuth state parameter to wait for
|
||||
timeout: Maximum time to wait in seconds
|
||||
|
||||
Returns:
|
||||
Authorization code
|
||||
|
||||
Raises:
|
||||
asyncio.TimeoutError: If callback not received within timeout
|
||||
ValueError: If state doesn't match
|
||||
"""
|
||||
# Check if already received
|
||||
code = self.get_code(state)
|
||||
if code:
|
||||
return code
|
||||
|
||||
# Create event for this state
|
||||
with self._lock:
|
||||
if state not in self._events:
|
||||
self._events[state] = threading.Event()
|
||||
event = self._events[state]
|
||||
|
||||
# Poll for callback with async sleep
|
||||
start_time = asyncio.get_event_loop().time()
|
||||
while True:
|
||||
if event.is_set():
|
||||
break
|
||||
|
||||
elapsed = asyncio.get_event_loop().time() - start_time
|
||||
if elapsed >= timeout:
|
||||
raise asyncio.TimeoutError(
|
||||
f"OAuth callback not received within {timeout} seconds"
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.05) # 50ms poll interval
|
||||
|
||||
# Get the code
|
||||
code = self.get_code(state)
|
||||
if not code:
|
||||
raise ValueError(f"OAuth callback received but no code for state '{state}'")
|
||||
|
||||
return code
|
||||
|
||||
def clear_state(self, state: str) -> None:
|
||||
"""
|
||||
Clear callback data for a state.
|
||||
|
||||
Should be called after successfully processing a callback.
|
||||
|
||||
Args:
|
||||
state: OAuth state parameter to clear
|
||||
"""
|
||||
with self._lock:
|
||||
self._callbacks.pop(state, None)
|
||||
self._events.pop(state, None)
|
||||
@@ -42,6 +42,8 @@ from .protocols import (
|
||||
MemoryProtocol,
|
||||
AsyncMemoryProtocol,
|
||||
ResettableMemoryProtocol,
|
||||
DeletableMemoryProtocol,
|
||||
AsyncDeletableMemoryProtocol,
|
||||
EntityMemoryProtocol,
|
||||
)
|
||||
|
||||
@@ -203,5 +205,7 @@ __all__ = [
|
||||
"MemoryProtocol",
|
||||
"AsyncMemoryProtocol",
|
||||
"ResettableMemoryProtocol",
|
||||
"DeletableMemoryProtocol",
|
||||
"AsyncDeletableMemoryProtocol",
|
||||
"EntityMemoryProtocol",
|
||||
]
|
||||
@@ -806,6 +806,220 @@ class FileMemory:
|
||||
|
||||
self._log("Cleared all memory")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Selective Deletion
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def delete_short_term(self, memory_id: str) -> bool:
|
||||
"""
|
||||
Delete a specific short-term memory by ID.
|
||||
|
||||
Args:
|
||||
memory_id: The unique ID of the memory to delete
|
||||
|
||||
Returns:
|
||||
True if memory was found and deleted, False otherwise
|
||||
"""
|
||||
for i, item in enumerate(self._short_term):
|
||||
if item.id == memory_id:
|
||||
del self._short_term[i]
|
||||
self._save_short_term()
|
||||
self._log(f"Deleted short-term memory: {memory_id}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def delete_long_term(self, memory_id: str) -> bool:
|
||||
"""
|
||||
Delete a specific long-term memory by ID.
|
||||
|
||||
Args:
|
||||
memory_id: The unique ID of the memory to delete
|
||||
|
||||
Returns:
|
||||
True if memory was found and deleted, False otherwise
|
||||
"""
|
||||
for i, item in enumerate(self._long_term):
|
||||
if item.id == memory_id:
|
||||
del self._long_term[i]
|
||||
self._save_long_term()
|
||||
self._log(f"Deleted long-term memory: {memory_id}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def delete_entity(self, name: str) -> bool:
|
||||
"""
|
||||
Delete an entity by name.
|
||||
|
||||
Args:
|
||||
name: The name of the entity to delete
|
||||
|
||||
Returns:
|
||||
True if entity was found and deleted, False otherwise
|
||||
"""
|
||||
# Find entity by name using existing helper
|
||||
entity = self._find_entity_by_name(name)
|
||||
if entity:
|
||||
# Delete by entity's ID (which is the dict key)
|
||||
if entity.id in self._entities:
|
||||
del self._entities[entity.id]
|
||||
self._save_entities()
|
||||
self._log(f"Deleted entity: {name}")
|
||||
return True
|
||||
|
||||
# Try direct ID match as fallback (in case name IS the ID)
|
||||
if name in self._entities:
|
||||
del self._entities[name]
|
||||
self._save_entities()
|
||||
self._log(f"Deleted entity by ID: {name}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def delete_episodic(self, memory_id: str, date: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Delete a specific episodic memory by ID.
|
||||
|
||||
Args:
|
||||
memory_id: The unique ID of the memory to delete
|
||||
date: Optional date string (YYYY-MM-DD) to narrow search
|
||||
|
||||
Returns:
|
||||
True if memory was found and deleted, False otherwise
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# Determine which files to search
|
||||
if date:
|
||||
dates_to_check = [date]
|
||||
else:
|
||||
# Check last 30 days
|
||||
dates_to_check = [
|
||||
(datetime.now() - timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
for i in range(30)
|
||||
]
|
||||
|
||||
for check_date in dates_to_check:
|
||||
episodic_file = self.episodic_path / f"{check_date}.json"
|
||||
if episodic_file.exists():
|
||||
data = self._read_json(episodic_file, [])
|
||||
for i, item in enumerate(data):
|
||||
if item.get("id") == memory_id:
|
||||
del data[i]
|
||||
self._write_json(episodic_file, data)
|
||||
self._log(f"Deleted episodic memory: {memory_id}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def delete_memory(
|
||||
self,
|
||||
memory_id: str,
|
||||
memory_type: Optional[str] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Delete a specific memory by ID.
|
||||
|
||||
This is the unified deletion method that searches across all memory types.
|
||||
Use this when you have a memory ID but don't know its type.
|
||||
|
||||
Particularly useful for:
|
||||
- Cleaning up image-based memories after processing to free context window
|
||||
- Removing outdated or incorrect information
|
||||
- Privacy compliance (selective erasure)
|
||||
|
||||
Args:
|
||||
memory_id: The unique ID of the memory to delete
|
||||
memory_type: Optional type hint to narrow search:
|
||||
'short_term', 'long_term', 'entity', 'episodic'
|
||||
If None, searches all types.
|
||||
|
||||
Returns:
|
||||
True if memory was found and deleted, False otherwise
|
||||
|
||||
Example:
|
||||
# Delete a specific memory after processing an image
|
||||
memory.delete_memory("abc123def456")
|
||||
|
||||
# Delete with type hint for faster lookup
|
||||
memory.delete_memory("abc123", memory_type="short_term")
|
||||
"""
|
||||
# If type specified, only search that type
|
||||
if memory_type == "short_term":
|
||||
return self.delete_short_term(memory_id)
|
||||
elif memory_type == "long_term":
|
||||
return self.delete_long_term(memory_id)
|
||||
elif memory_type == "entity":
|
||||
return self.delete_entity(memory_id)
|
||||
elif memory_type == "episodic":
|
||||
return self.delete_episodic(memory_id)
|
||||
|
||||
# Search all types
|
||||
if self.delete_short_term(memory_id):
|
||||
return True
|
||||
if self.delete_long_term(memory_id):
|
||||
return True
|
||||
if self.delete_entity(memory_id):
|
||||
return True
|
||||
if self.delete_episodic(memory_id):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def delete_memories(self, memory_ids: List[str]) -> int:
|
||||
"""
|
||||
Delete multiple memories by their IDs.
|
||||
|
||||
Args:
|
||||
memory_ids: List of memory IDs to delete
|
||||
|
||||
Returns:
|
||||
Number of memories successfully deleted
|
||||
"""
|
||||
count = 0
|
||||
for memory_id in memory_ids:
|
||||
if self.delete_memory(memory_id):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def delete_memories_matching(
|
||||
self,
|
||||
query: str,
|
||||
memory_types: Optional[List[str]] = None,
|
||||
limit: int = 10
|
||||
) -> int:
|
||||
"""
|
||||
Delete memories matching a search query.
|
||||
|
||||
Useful for bulk cleanup of related memories, e.g., all image-related
|
||||
context after finishing an image analysis session.
|
||||
|
||||
Args:
|
||||
query: Search query to match memories
|
||||
memory_types: Optional list of types to search
|
||||
limit: Maximum number of memories to delete
|
||||
|
||||
Returns:
|
||||
Number of memories deleted
|
||||
"""
|
||||
# Search for matching memories
|
||||
results = self.search(query, memory_types=memory_types, limit=limit)
|
||||
|
||||
# Extract IDs and delete
|
||||
deleted = 0
|
||||
for result in results:
|
||||
item = result.get("item", {})
|
||||
memory_id = item.get("id")
|
||||
memory_type = result.get("type")
|
||||
|
||||
if memory_id:
|
||||
if self.delete_memory(memory_id, memory_type=memory_type):
|
||||
deleted += 1
|
||||
|
||||
if deleted:
|
||||
self._log(f"Deleted {deleted} memories matching '{query}'")
|
||||
|
||||
return deleted
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""Get memory statistics."""
|
||||
episodic_count = sum(1 for _ in self.episodic_path.glob("*.json"))
|
||||
@@ -1222,6 +1436,66 @@ Summary:"""
|
||||
else:
|
||||
return {"error": "Usage: /memory clear [short|all]"}
|
||||
|
||||
elif action == "delete":
|
||||
if not args:
|
||||
return {"error": "Usage: /memory delete <id> or /memory delete --query <search_query>"}
|
||||
|
||||
# Handle --query flag for bulk deletion
|
||||
if args.startswith("--query "):
|
||||
query = args[8:] # Remove "--query " prefix
|
||||
if not query:
|
||||
return {"error": "Usage: /memory delete --query <search_query>"}
|
||||
deleted = self.delete_memories_matching(query, limit=10)
|
||||
return {
|
||||
"action": "delete",
|
||||
"type": "query",
|
||||
"query": query,
|
||||
"deleted_count": deleted
|
||||
}
|
||||
|
||||
# Single ID deletion
|
||||
success = self.delete_memory(args)
|
||||
return {
|
||||
"action": "delete",
|
||||
"type": "single",
|
||||
"id": args,
|
||||
"success": success
|
||||
}
|
||||
|
||||
elif action == "list":
|
||||
# List memories with IDs for easy deletion reference
|
||||
memory_type = args.lower() if args else "all"
|
||||
items = []
|
||||
|
||||
if memory_type in ("all", "short", "short_term"):
|
||||
for item in self.get_short_term(limit=20):
|
||||
items.append({
|
||||
"type": "short_term",
|
||||
"id": item.id,
|
||||
"content": item.content[:100] + "..." if len(item.content) > 100 else item.content,
|
||||
"importance": item.importance
|
||||
})
|
||||
|
||||
if memory_type in ("all", "long", "long_term"):
|
||||
for item in self.get_long_term(limit=20):
|
||||
items.append({
|
||||
"type": "long_term",
|
||||
"id": item.id,
|
||||
"content": item.content[:100] + "..." if len(item.content) > 100 else item.content,
|
||||
"importance": item.importance
|
||||
})
|
||||
|
||||
if memory_type in ("all", "entity", "entities"):
|
||||
for name, entity in self._entities.items():
|
||||
items.append({
|
||||
"type": "entity",
|
||||
"id": name,
|
||||
"name": entity.name,
|
||||
"entity_type": entity.entity_type
|
||||
})
|
||||
|
||||
return {"action": "list", "filter": memory_type, "items": items}
|
||||
|
||||
elif action == "search":
|
||||
if not args:
|
||||
return {"error": "Usage: /memory search <query>"}
|
||||
@@ -1274,8 +1548,11 @@ Summary:"""
|
||||
"action": "help",
|
||||
"commands": {
|
||||
"/memory show": "Display memory stats and recent items",
|
||||
"/memory list [short|long|entity]": "List memories with IDs for deletion",
|
||||
"/memory add <content>": "Add to long-term memory",
|
||||
"/memory clear [short|all]": "Clear memory",
|
||||
"/memory delete <id>": "Delete a specific memory by ID",
|
||||
"/memory delete --query <q>": "Delete memories matching query",
|
||||
"/memory clear [short|all]": "Clear all memory (bulk)",
|
||||
"/memory search <query>": "Search memories",
|
||||
"/memory save <name>": "Save session",
|
||||
"/memory resume <name>": "Resume session",
|
||||
|
||||
@@ -463,52 +463,19 @@ class Memory:
|
||||
self._log_verbose(f"Error creating vector search indexes: {e}", logging.WARNING)
|
||||
|
||||
def _get_embedding(self, text: str) -> List[float]:
|
||||
"""Get embedding for text using available embedding services."""
|
||||
"""Get embedding for text using the unified embedding module."""
|
||||
try:
|
||||
if _check_litellm():
|
||||
# Use LiteLLM for consistency with the rest of the codebase
|
||||
import litellm
|
||||
|
||||
response = litellm.embedding(
|
||||
model=self.embedding_model,
|
||||
input=text
|
||||
)
|
||||
return response.data[0]["embedding"]
|
||||
elif _check_openai():
|
||||
# Fallback to OpenAI client
|
||||
from openai import OpenAI
|
||||
client = OpenAI()
|
||||
|
||||
response = client.embeddings.create(
|
||||
input=text,
|
||||
model=self.embedding_model
|
||||
)
|
||||
return response.data[0].embedding
|
||||
else:
|
||||
self._log_verbose("Neither litellm nor openai available for embeddings", logging.WARNING)
|
||||
return None
|
||||
from praisonaiagents.embedding import embedding
|
||||
result = embedding(text, model=self.embedding_model)
|
||||
return result.embeddings[0] if result.embeddings else None
|
||||
except Exception as e:
|
||||
self._log_verbose(f"Error getting embedding: {e}", logging.ERROR)
|
||||
return None
|
||||
|
||||
def _get_embedding_dimensions(self, model_name: str) -> int:
|
||||
"""Get embedding dimensions based on model name."""
|
||||
# Common embedding model dimensions
|
||||
model_dimensions = {
|
||||
"text-embedding-3-small": 1536,
|
||||
"text-embedding-3-large": 3072,
|
||||
"text-embedding-ada-002": 1536,
|
||||
"text-embedding-002": 1536,
|
||||
# Add more models as needed
|
||||
}
|
||||
|
||||
# Check if model name contains known model identifiers
|
||||
for model_key, dimensions in model_dimensions.items():
|
||||
if model_key in model_name.lower():
|
||||
return dimensions
|
||||
|
||||
# Default to 1536 for unknown models (OpenAI standard)
|
||||
return 1536
|
||||
from praisonaiagents.embedding import get_dimensions
|
||||
return get_dimensions(model_name)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Basic Quality Score Computation
|
||||
@@ -690,27 +657,12 @@ class Memory:
|
||||
|
||||
elif self.use_rag and hasattr(self, "chroma_col"):
|
||||
try:
|
||||
if _check_litellm():
|
||||
# Use LiteLLM for consistency with the rest of the codebase
|
||||
import litellm
|
||||
|
||||
response = litellm.embedding(
|
||||
model=self.embedding_model,
|
||||
input=query
|
||||
)
|
||||
query_embedding = response.data[0]["embedding"]
|
||||
elif _check_openai():
|
||||
# Fallback to OpenAI client
|
||||
from openai import OpenAI
|
||||
client = OpenAI()
|
||||
|
||||
response = client.embeddings.create(
|
||||
input=query,
|
||||
model=self.embedding_model
|
||||
)
|
||||
query_embedding = response.data[0].embedding
|
||||
else:
|
||||
self._log_verbose("Neither litellm nor openai available for embeddings", logging.WARNING)
|
||||
from praisonaiagents.embedding import embedding
|
||||
result = embedding(query, model=self.embedding_model)
|
||||
query_embedding = result.embeddings[0] if result.embeddings else None
|
||||
|
||||
if query_embedding is None:
|
||||
self._log_verbose("Failed to get embedding for query", logging.WARNING)
|
||||
return []
|
||||
|
||||
resp = self.chroma_col.query(
|
||||
@@ -853,39 +805,20 @@ class Memory:
|
||||
# Store in vector database if enabled
|
||||
if self.use_rag and hasattr(self, "chroma_col"):
|
||||
try:
|
||||
if _check_litellm():
|
||||
# Use LiteLLM for consistency with the rest of the codebase
|
||||
import litellm
|
||||
|
||||
logger.info("Getting embeddings from LiteLLM...")
|
||||
logger.trace(f"Embedding input text: {text}")
|
||||
|
||||
response = litellm.embedding(
|
||||
model=self.embedding_model,
|
||||
input=text
|
||||
)
|
||||
embedding = response.data[0]["embedding"]
|
||||
logger.info("Successfully got embeddings from LiteLLM")
|
||||
logger.trace(f"Received embedding of length: {len(embedding)}")
|
||||
|
||||
elif _check_openai():
|
||||
# Fallback to OpenAI client
|
||||
from openai import OpenAI
|
||||
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
|
||||
|
||||
logger.info("Getting embeddings from OpenAI...")
|
||||
logger.trace(f"Embedding input text: {text}")
|
||||
|
||||
response = client.embeddings.create(
|
||||
input=text,
|
||||
model=self.embedding_model
|
||||
)
|
||||
embedding = response.data[0].embedding
|
||||
logger.info("Successfully got embeddings from OpenAI")
|
||||
logger.trace(f"Received embedding of length: {len(embedding)}")
|
||||
else:
|
||||
logger.warning("Neither litellm nor openai available for embeddings")
|
||||
from praisonaiagents.embedding import embedding as get_embedding
|
||||
|
||||
logger.info("Getting embeddings...")
|
||||
logger.trace(f"Embedding input text: {text}")
|
||||
|
||||
result = get_embedding(text, model=self.embedding_model)
|
||||
embedding = result.embeddings[0] if result.embeddings else None
|
||||
|
||||
if embedding is None:
|
||||
logger.warning("Failed to get embedding")
|
||||
return
|
||||
|
||||
logger.info("Successfully got embeddings")
|
||||
logger.trace(f"Received embedding of length: {len(embedding)}")
|
||||
|
||||
# Sanitize metadata for ChromaDB
|
||||
sanitized_metadata = self._sanitize_metadata(metadata)
|
||||
@@ -1006,27 +939,12 @@ class Memory:
|
||||
|
||||
elif self.use_rag and hasattr(self, "chroma_col"):
|
||||
try:
|
||||
if _check_litellm():
|
||||
# Use LiteLLM for consistency with the rest of the codebase
|
||||
import litellm
|
||||
|
||||
response = litellm.embedding(
|
||||
model=self.embedding_model,
|
||||
input=query
|
||||
)
|
||||
query_embedding = response.data[0]["embedding"]
|
||||
elif _check_openai():
|
||||
# Fallback to OpenAI client
|
||||
from openai import OpenAI
|
||||
client = OpenAI()
|
||||
|
||||
response = client.embeddings.create(
|
||||
input=query,
|
||||
model=self.embedding_model
|
||||
)
|
||||
query_embedding = response.data[0].embedding
|
||||
else:
|
||||
self._log_verbose("Neither litellm nor openai available for embeddings", logging.WARNING)
|
||||
from praisonaiagents.embedding import embedding as get_embedding
|
||||
result = get_embedding(query, model=self.embedding_model)
|
||||
query_embedding = result.embeddings[0] if result.embeddings else None
|
||||
|
||||
if query_embedding is None:
|
||||
self._log_verbose("Failed to get embedding for query", logging.WARNING)
|
||||
return []
|
||||
|
||||
# Search ChromaDB with embedding
|
||||
@@ -1117,6 +1035,213 @@ class Memory:
|
||||
self.chroma_client.reset() # entire DB
|
||||
self._init_chroma() # re-init fresh
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Selective Deletion Methods
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def delete_short_term(self, memory_id: str) -> bool:
|
||||
"""
|
||||
Delete a specific short-term memory by ID.
|
||||
|
||||
Args:
|
||||
memory_id: The unique ID of the memory to delete
|
||||
|
||||
Returns:
|
||||
True if memory was found and deleted, False otherwise
|
||||
"""
|
||||
deleted = False
|
||||
|
||||
# Delete from SQLite
|
||||
try:
|
||||
conn = sqlite3.connect(self.short_db)
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM short_mem WHERE id = ?", (memory_id,)
|
||||
)
|
||||
if cursor.rowcount > 0:
|
||||
deleted = True
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
self._log_verbose(f"Error deleting from SQLite short-term: {e}", logging.ERROR)
|
||||
|
||||
# Delete from MongoDB if enabled
|
||||
if self.use_mongodb and hasattr(self, "mongo_short_term"):
|
||||
try:
|
||||
result = self.mongo_short_term.delete_one({"_id": memory_id})
|
||||
if result.deleted_count > 0:
|
||||
deleted = True
|
||||
except Exception as e:
|
||||
self._log_verbose(f"Error deleting from MongoDB short-term: {e}", logging.ERROR)
|
||||
|
||||
if deleted:
|
||||
self._log_verbose(f"Deleted short-term memory: {memory_id}")
|
||||
|
||||
return deleted
|
||||
|
||||
def delete_long_term(self, memory_id: str) -> bool:
|
||||
"""
|
||||
Delete a specific long-term memory by ID.
|
||||
|
||||
Works across all backends: SQLite, ChromaDB, Mem0, and MongoDB.
|
||||
|
||||
Args:
|
||||
memory_id: The unique ID of the memory to delete
|
||||
|
||||
Returns:
|
||||
True if memory was found and deleted, False otherwise
|
||||
"""
|
||||
deleted = False
|
||||
|
||||
# Delete from SQLite
|
||||
try:
|
||||
conn = sqlite3.connect(self.long_db)
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM long_mem WHERE id = ?", (memory_id,)
|
||||
)
|
||||
if cursor.rowcount > 0:
|
||||
deleted = True
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
self._log_verbose(f"Error deleting from SQLite long-term: {e}", logging.ERROR)
|
||||
|
||||
# Delete from ChromaDB if enabled
|
||||
if self.use_rag and hasattr(self, "chroma_col"):
|
||||
try:
|
||||
# ChromaDB delete by ID
|
||||
self.chroma_col.delete(ids=[memory_id])
|
||||
deleted = True # ChromaDB doesn't raise on non-existent ID
|
||||
except Exception as e:
|
||||
self._log_verbose(f"Error deleting from ChromaDB: {e}", logging.ERROR)
|
||||
|
||||
# Delete from Mem0 if enabled
|
||||
if self.use_mem0 and hasattr(self, "mem0_client"):
|
||||
try:
|
||||
# Mem0 has a delete method
|
||||
self.mem0_client.delete(memory_id)
|
||||
deleted = True
|
||||
except Exception as e:
|
||||
self._log_verbose(f"Error deleting from Mem0: {e}", logging.ERROR)
|
||||
|
||||
# Delete from MongoDB if enabled
|
||||
if self.use_mongodb and hasattr(self, "mongo_long_term"):
|
||||
try:
|
||||
result = self.mongo_long_term.delete_one({"_id": memory_id})
|
||||
if result.deleted_count > 0:
|
||||
deleted = True
|
||||
except Exception as e:
|
||||
self._log_verbose(f"Error deleting from MongoDB long-term: {e}", logging.ERROR)
|
||||
|
||||
if deleted:
|
||||
self._log_verbose(f"Deleted long-term memory: {memory_id}")
|
||||
|
||||
return deleted
|
||||
|
||||
def delete_memory(
|
||||
self,
|
||||
memory_id: str,
|
||||
memory_type: Optional[str] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Delete a specific memory by ID.
|
||||
|
||||
This is the unified deletion method that searches across all memory types
|
||||
and all backends (SQLite, ChromaDB, Mem0, MongoDB).
|
||||
|
||||
Particularly useful for:
|
||||
- Cleaning up image-based memories after processing to free context window
|
||||
- Removing outdated or incorrect information
|
||||
- Privacy compliance (selective erasure)
|
||||
|
||||
Args:
|
||||
memory_id: The unique ID of the memory to delete
|
||||
memory_type: Optional type hint to narrow search:
|
||||
'short_term', 'long_term'
|
||||
If None, searches all types.
|
||||
|
||||
Returns:
|
||||
True if memory was found and deleted, False otherwise
|
||||
|
||||
Example:
|
||||
# Delete a specific image analysis memory
|
||||
memory.delete_memory("1705123456789")
|
||||
|
||||
# Delete with type hint for faster lookup
|
||||
memory.delete_memory("1705123456789", memory_type="short_term")
|
||||
"""
|
||||
# If type specified, only search that type
|
||||
if memory_type == "short_term":
|
||||
return self.delete_short_term(memory_id)
|
||||
elif memory_type == "long_term":
|
||||
return self.delete_long_term(memory_id)
|
||||
|
||||
# Search both types
|
||||
if self.delete_short_term(memory_id):
|
||||
return True
|
||||
if self.delete_long_term(memory_id):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def delete_memories(self, memory_ids: List[str]) -> int:
|
||||
"""
|
||||
Delete multiple memories by their IDs.
|
||||
|
||||
Args:
|
||||
memory_ids: List of memory IDs to delete
|
||||
|
||||
Returns:
|
||||
Number of memories successfully deleted
|
||||
"""
|
||||
count = 0
|
||||
for memory_id in memory_ids:
|
||||
if self.delete_memory(memory_id):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def delete_memories_matching(
|
||||
self,
|
||||
query: str,
|
||||
memory_type: Optional[str] = None,
|
||||
limit: int = 10
|
||||
) -> int:
|
||||
"""
|
||||
Delete memories matching a search query.
|
||||
|
||||
Useful for bulk cleanup of related memories, e.g., all image-related
|
||||
context after finishing an image analysis session.
|
||||
|
||||
Args:
|
||||
query: Search query to match memories
|
||||
memory_type: Optional type ('short_term' or 'long_term')
|
||||
limit: Maximum number of memories to delete
|
||||
|
||||
Returns:
|
||||
Number of memories deleted
|
||||
"""
|
||||
deleted = 0
|
||||
|
||||
# Search short-term if applicable
|
||||
if memory_type in (None, "short_term"):
|
||||
results = self.search_short_term(query, limit=limit)
|
||||
for result in results:
|
||||
memory_id = result.get("id")
|
||||
if memory_id and self.delete_short_term(memory_id):
|
||||
deleted += 1
|
||||
|
||||
# Search long-term if applicable
|
||||
if memory_type in (None, "long_term"):
|
||||
results = self.search_long_term(query, limit=limit)
|
||||
for result in results:
|
||||
memory_id = result.get("id")
|
||||
if memory_id and self.delete_long_term(memory_id):
|
||||
deleted += 1
|
||||
|
||||
if deleted:
|
||||
self._log_verbose(f"Deleted {deleted} memories matching '{query}'")
|
||||
|
||||
return deleted
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Entity Memory Methods
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@@ -138,6 +138,77 @@ class ResettableMemoryProtocol(MemoryProtocol, Protocol):
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DeletableMemoryProtocol(MemoryProtocol, Protocol):
|
||||
"""
|
||||
Protocol for memory that supports selective deletion.
|
||||
|
||||
Enables removing specific memories by ID without clearing all data.
|
||||
Essential for:
|
||||
- Privacy compliance (GDPR right to erasure)
|
||||
- Context window management (removing large image-based memories)
|
||||
- Correcting outdated information
|
||||
|
||||
Example:
|
||||
```python
|
||||
class MyMemory:
|
||||
def delete_memory(self, memory_id: str) -> bool:
|
||||
if memory_id in self._store:
|
||||
del self._store[memory_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
def delete_memories(self, memory_ids: List[str]) -> int:
|
||||
count = 0
|
||||
for mid in memory_ids:
|
||||
if self.delete_memory(mid):
|
||||
count += 1
|
||||
return count
|
||||
```
|
||||
"""
|
||||
|
||||
def delete_memory(self, memory_id: str, memory_type: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Delete a specific memory by ID.
|
||||
|
||||
Args:
|
||||
memory_id: The unique identifier of the memory to delete
|
||||
memory_type: Optional type hint (short_term, long_term, entity)
|
||||
If None, searches all types.
|
||||
|
||||
Returns:
|
||||
True if memory was found and deleted, False otherwise
|
||||
"""
|
||||
...
|
||||
|
||||
def delete_memories(self, memory_ids: List[str]) -> int:
|
||||
"""
|
||||
Delete multiple memories by their IDs.
|
||||
|
||||
Args:
|
||||
memory_ids: List of memory IDs to delete
|
||||
|
||||
Returns:
|
||||
Number of memories successfully deleted
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AsyncDeletableMemoryProtocol(Protocol):
|
||||
"""
|
||||
Async Protocol for memory deletion operations.
|
||||
"""
|
||||
|
||||
async def adelete_memory(self, memory_id: str, memory_type: Optional[str] = None) -> bool:
|
||||
"""Async delete a specific memory by ID."""
|
||||
...
|
||||
|
||||
async def adelete_memories(self, memory_ids: List[str]) -> int:
|
||||
"""Async delete multiple memories by their IDs."""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AsyncMemoryProtocol(Protocol):
|
||||
"""
|
||||
@@ -207,6 +278,9 @@ class EntityMemoryProtocol(Protocol):
|
||||
__all__ = [
|
||||
'MemoryProtocol',
|
||||
'ResettableMemoryProtocol',
|
||||
'DeletableMemoryProtocol',
|
||||
'AsyncDeletableMemoryProtocol',
|
||||
'AsyncMemoryProtocol',
|
||||
'EntityMemoryProtocol',
|
||||
]
|
||||
|
||||
@@ -1152,12 +1152,18 @@ class WorkflowManager:
|
||||
text: str,
|
||||
variables: Dict[str, Any]
|
||||
) -> str:
|
||||
"""Substitute {{variable}} placeholders in text."""
|
||||
def replace(match):
|
||||
var_name = match.group(1).strip()
|
||||
return str(variables.get(var_name, match.group(0)))
|
||||
"""
|
||||
Substitute {{variable}} placeholders in text.
|
||||
|
||||
return re.sub(r'\{\{(\w+)\}\}', replace, text)
|
||||
Delegates to shared substitute_variables() utility for DRY compliance.
|
||||
|
||||
Resolution order:
|
||||
1. Dynamic variable providers ({{now}}, {{today}}, {{uuid}}, etc.)
|
||||
2. Static variables from workflow variables dict
|
||||
3. Keep original placeholder if not found
|
||||
"""
|
||||
from praisonaiagents.utils.variables import substitute_variables
|
||||
return substitute_variables(text, variables)
|
||||
|
||||
def _build_step_context(
|
||||
self,
|
||||
|
||||
@@ -44,6 +44,13 @@ __all__ = [
|
||||
# Redaction
|
||||
"redact_dict",
|
||||
"REDACT_KEYS",
|
||||
# Context events (for replay)
|
||||
"ContextEvent",
|
||||
"ContextEventType",
|
||||
"ContextTraceSink",
|
||||
"ContextNoOpSink",
|
||||
"ContextListSink",
|
||||
"ContextTraceEmitter",
|
||||
]
|
||||
|
||||
|
||||
@@ -65,4 +72,17 @@ def __getattr__(name: str):
|
||||
from .redact import redact_dict, REDACT_KEYS
|
||||
return locals()[name]
|
||||
|
||||
# Context events (for replay)
|
||||
if name in ("ContextEvent", "ContextEventType"):
|
||||
from .context_events import ContextEvent, ContextEventType
|
||||
return locals()[name]
|
||||
|
||||
if name in ("ContextTraceSink", "ContextNoOpSink", "ContextListSink"):
|
||||
from .context_events import ContextTraceSink, ContextNoOpSink, ContextListSink
|
||||
return locals()[name]
|
||||
|
||||
if name == "ContextTraceEmitter":
|
||||
from .context_events import ContextTraceEmitter
|
||||
return ContextTraceEmitter
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -0,0 +1,481 @@
|
||||
"""
|
||||
Context Events for PraisonAI Agents.
|
||||
|
||||
Provides context-level tracing for replay functionality.
|
||||
Tracks context changes during agent execution for debugging and replay.
|
||||
|
||||
Zero Performance Impact:
|
||||
- NoOpSink is the default (zero overhead when not used)
|
||||
- Disabled emitter has near-zero overhead
|
||||
- Uses __slots__ for memory efficiency
|
||||
|
||||
Schema Version: 1.0
|
||||
"""
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
|
||||
import json
|
||||
|
||||
|
||||
CONTEXT_SCHEMA_VERSION = "1.0"
|
||||
|
||||
|
||||
class ContextEventType(str, Enum):
|
||||
"""Types of context events for replay."""
|
||||
SESSION_START = "session_start"
|
||||
SESSION_END = "session_end"
|
||||
AGENT_START = "agent_start"
|
||||
AGENT_END = "agent_end"
|
||||
AGENT_HANDOFF = "agent_handoff"
|
||||
MESSAGE_ADDED = "message_added"
|
||||
TOOL_CALL_START = "tool_call_start"
|
||||
TOOL_CALL_END = "tool_call_end"
|
||||
LLM_REQUEST = "llm_request"
|
||||
LLM_RESPONSE = "llm_response"
|
||||
CONTEXT_SNAPSHOT = "context_snapshot"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContextEvent:
|
||||
"""
|
||||
A single context event for replay.
|
||||
|
||||
Represents a discrete change in agent context during execution.
|
||||
Designed for minimal memory footprint and fast serialization.
|
||||
|
||||
Attributes:
|
||||
event_type: Type of context event
|
||||
timestamp: Unix timestamp when event occurred
|
||||
session_id: Session identifier for grouping events
|
||||
agent_name: Name of the agent (if applicable)
|
||||
sequence_num: Sequential event number within session
|
||||
messages_count: Number of messages in context at this point
|
||||
tokens_used: Tokens used in context at this point
|
||||
tokens_budget: Total token budget available
|
||||
data: Event-specific data (tool args, message content, etc.)
|
||||
"""
|
||||
event_type: ContextEventType
|
||||
timestamp: float
|
||||
session_id: str
|
||||
agent_name: Optional[str] = None
|
||||
sequence_num: int = 0
|
||||
messages_count: int = 0
|
||||
tokens_used: int = 0
|
||||
tokens_budget: int = 0
|
||||
data: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
"schema_version": CONTEXT_SCHEMA_VERSION,
|
||||
"event_type": self.event_type.value if isinstance(self.event_type, ContextEventType) else self.event_type,
|
||||
"timestamp": self.timestamp,
|
||||
"session_id": self.session_id,
|
||||
"agent_name": self.agent_name,
|
||||
"sequence_num": self.sequence_num,
|
||||
"messages_count": self.messages_count,
|
||||
"tokens_used": self.tokens_used,
|
||||
"tokens_budget": self.tokens_budget,
|
||||
"data": self.data,
|
||||
}
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Convert to JSON string."""
|
||||
return json.dumps(self.to_dict(), default=str)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "ContextEvent":
|
||||
"""Create ContextEvent from dictionary."""
|
||||
event_type_str = data.get("event_type", "")
|
||||
|
||||
# Convert string to enum
|
||||
try:
|
||||
event_type = ContextEventType(event_type_str)
|
||||
except ValueError:
|
||||
# Fallback for unknown event types
|
||||
event_type = ContextEventType.CONTEXT_SNAPSHOT
|
||||
|
||||
return cls(
|
||||
event_type=event_type,
|
||||
timestamp=data.get("timestamp", 0.0),
|
||||
session_id=data.get("session_id", ""),
|
||||
agent_name=data.get("agent_name"),
|
||||
sequence_num=data.get("sequence_num", 0),
|
||||
messages_count=data.get("messages_count", 0),
|
||||
tokens_used=data.get("tokens_used", 0),
|
||||
tokens_budget=data.get("tokens_budget", 0),
|
||||
data=data.get("data", {}),
|
||||
)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ContextTraceSink(Protocol):
|
||||
"""
|
||||
Protocol for context trace event sinks.
|
||||
|
||||
Implementations receive context events and handle them
|
||||
(e.g., write to file, collect in memory, send to server).
|
||||
"""
|
||||
|
||||
def emit(self, event: ContextEvent) -> None:
|
||||
"""Emit a context event."""
|
||||
...
|
||||
|
||||
def flush(self) -> None:
|
||||
"""Flush any buffered events."""
|
||||
...
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the sink and release resources."""
|
||||
...
|
||||
|
||||
|
||||
class ContextNoOpSink:
|
||||
"""
|
||||
No-operation sink that discards all events.
|
||||
|
||||
Used as the default sink when context tracing is disabled.
|
||||
Has near-zero overhead.
|
||||
"""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
def emit(self, event: ContextEvent) -> None:
|
||||
"""Discard the event."""
|
||||
pass
|
||||
|
||||
def flush(self) -> None:
|
||||
"""No-op."""
|
||||
pass
|
||||
|
||||
def close(self) -> None:
|
||||
"""No-op."""
|
||||
pass
|
||||
|
||||
|
||||
class ContextListSink:
|
||||
"""
|
||||
Sink that collects events in a list.
|
||||
|
||||
Useful for testing and programmatic access to context events.
|
||||
"""
|
||||
|
||||
__slots__ = ("_events",)
|
||||
|
||||
def __init__(self):
|
||||
self._events: List[ContextEvent] = []
|
||||
|
||||
def emit(self, event: ContextEvent) -> None:
|
||||
"""Add event to the list."""
|
||||
self._events.append(event)
|
||||
|
||||
def flush(self) -> None:
|
||||
"""No-op for list sink."""
|
||||
pass
|
||||
|
||||
def close(self) -> None:
|
||||
"""No-op for list sink."""
|
||||
pass
|
||||
|
||||
def get_events(self) -> List[ContextEvent]:
|
||||
"""Get all collected events."""
|
||||
return self._events.copy()
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all collected events."""
|
||||
self._events.clear()
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Return number of events."""
|
||||
return len(self._events)
|
||||
|
||||
def __iter__(self):
|
||||
"""Iterate over events."""
|
||||
return iter(self._events)
|
||||
|
||||
|
||||
class ContextTraceEmitter:
|
||||
"""
|
||||
Emitter for context trace events.
|
||||
|
||||
Provides a high-level API for emitting context events during
|
||||
agent execution. Handles sequence numbering and redaction.
|
||||
|
||||
Usage:
|
||||
sink = ContextListSink()
|
||||
emitter = ContextTraceEmitter(sink=sink, session_id="my-session")
|
||||
|
||||
emitter.session_start()
|
||||
emitter.agent_start("researcher")
|
||||
emitter.message_added("researcher", "user", "Hello", 1, 10)
|
||||
emitter.agent_end("researcher")
|
||||
emitter.session_end()
|
||||
"""
|
||||
|
||||
__slots__ = ("_sink", "_session_id", "_enabled", "_redact", "_sequence")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sink: Optional[ContextTraceSink] = None,
|
||||
session_id: str = "",
|
||||
enabled: bool = True,
|
||||
redact: bool = True,
|
||||
):
|
||||
"""
|
||||
Initialize context trace emitter.
|
||||
|
||||
Args:
|
||||
sink: Sink to emit events to (default: ContextNoOpSink)
|
||||
session_id: Session identifier for all events
|
||||
enabled: Whether tracing is enabled
|
||||
redact: Whether to redact sensitive data
|
||||
"""
|
||||
self._sink = sink if sink is not None else ContextNoOpSink()
|
||||
self._session_id = session_id
|
||||
self._enabled = enabled
|
||||
self._redact = redact
|
||||
self._sequence = 0
|
||||
|
||||
def _emit(self, event: ContextEvent) -> None:
|
||||
"""Internal emit with enabled check and sequence assignment."""
|
||||
if not self._enabled:
|
||||
return
|
||||
event.sequence_num = self._sequence
|
||||
self._sequence += 1
|
||||
self._sink.emit(event)
|
||||
|
||||
def _redact_dict(self, data: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||
"""Redact sensitive data from dictionary if enabled."""
|
||||
if data is None or not self._redact:
|
||||
return data
|
||||
|
||||
from .redact import redact_dict
|
||||
return redact_dict(data)
|
||||
|
||||
def session_start(self, metadata: Optional[Dict[str, Any]] = None) -> None:
|
||||
"""Emit session start event."""
|
||||
self._emit(ContextEvent(
|
||||
event_type=ContextEventType.SESSION_START,
|
||||
timestamp=time.time(),
|
||||
session_id=self._session_id,
|
||||
data=metadata or {},
|
||||
))
|
||||
|
||||
def session_end(self, metadata: Optional[Dict[str, Any]] = None) -> None:
|
||||
"""Emit session end event."""
|
||||
self._emit(ContextEvent(
|
||||
event_type=ContextEventType.SESSION_END,
|
||||
timestamp=time.time(),
|
||||
session_id=self._session_id,
|
||||
data=metadata or {},
|
||||
))
|
||||
|
||||
def agent_start(
|
||||
self,
|
||||
agent_name: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Emit agent start event."""
|
||||
self._emit(ContextEvent(
|
||||
event_type=ContextEventType.AGENT_START,
|
||||
timestamp=time.time(),
|
||||
session_id=self._session_id,
|
||||
agent_name=agent_name,
|
||||
data=metadata or {},
|
||||
))
|
||||
|
||||
def agent_end(
|
||||
self,
|
||||
agent_name: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Emit agent end event."""
|
||||
self._emit(ContextEvent(
|
||||
event_type=ContextEventType.AGENT_END,
|
||||
timestamp=time.time(),
|
||||
session_id=self._session_id,
|
||||
agent_name=agent_name,
|
||||
data=metadata or {},
|
||||
))
|
||||
|
||||
def message_added(
|
||||
self,
|
||||
agent_name: str,
|
||||
role: str,
|
||||
content: str,
|
||||
messages_count: int,
|
||||
tokens_used: int,
|
||||
tokens_budget: int = 0,
|
||||
) -> None:
|
||||
"""Emit message added event."""
|
||||
# Truncate long content for storage
|
||||
truncated_content = content[:1000] + "..." if len(content) > 1000 else content
|
||||
|
||||
self._emit(ContextEvent(
|
||||
event_type=ContextEventType.MESSAGE_ADDED,
|
||||
timestamp=time.time(),
|
||||
session_id=self._session_id,
|
||||
agent_name=agent_name,
|
||||
messages_count=messages_count,
|
||||
tokens_used=tokens_used,
|
||||
tokens_budget=tokens_budget,
|
||||
data={
|
||||
"role": role,
|
||||
"content": truncated_content,
|
||||
},
|
||||
))
|
||||
|
||||
def tool_call_start(
|
||||
self,
|
||||
agent_name: str,
|
||||
tool_name: str,
|
||||
tool_args: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Emit tool call start event."""
|
||||
self._emit(ContextEvent(
|
||||
event_type=ContextEventType.TOOL_CALL_START,
|
||||
timestamp=time.time(),
|
||||
session_id=self._session_id,
|
||||
agent_name=agent_name,
|
||||
data={
|
||||
"tool_name": tool_name,
|
||||
"tool_args": self._redact_dict(tool_args) if tool_args else {},
|
||||
},
|
||||
))
|
||||
|
||||
def tool_call_end(
|
||||
self,
|
||||
agent_name: str,
|
||||
tool_name: str,
|
||||
result: Optional[str] = None,
|
||||
duration_ms: float = 0.0,
|
||||
error: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Emit tool call end event."""
|
||||
# Truncate long results
|
||||
truncated_result = None
|
||||
if result:
|
||||
truncated_result = result[:500] + "..." if len(result) > 500 else result
|
||||
|
||||
self._emit(ContextEvent(
|
||||
event_type=ContextEventType.TOOL_CALL_END,
|
||||
timestamp=time.time(),
|
||||
session_id=self._session_id,
|
||||
agent_name=agent_name,
|
||||
data={
|
||||
"tool_name": tool_name,
|
||||
"result": truncated_result,
|
||||
"duration_ms": duration_ms,
|
||||
"error": error,
|
||||
},
|
||||
))
|
||||
|
||||
def llm_request(
|
||||
self,
|
||||
agent_name: str,
|
||||
messages_count: int,
|
||||
tokens_used: int,
|
||||
tokens_budget: int = 0,
|
||||
model: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Emit LLM request event."""
|
||||
self._emit(ContextEvent(
|
||||
event_type=ContextEventType.LLM_REQUEST,
|
||||
timestamp=time.time(),
|
||||
session_id=self._session_id,
|
||||
agent_name=agent_name,
|
||||
messages_count=messages_count,
|
||||
tokens_used=tokens_used,
|
||||
tokens_budget=tokens_budget,
|
||||
data={
|
||||
"model": model,
|
||||
},
|
||||
))
|
||||
|
||||
def llm_response(
|
||||
self,
|
||||
agent_name: str,
|
||||
response_tokens: int = 0,
|
||||
duration_ms: float = 0.0,
|
||||
finish_reason: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Emit LLM response event."""
|
||||
self._emit(ContextEvent(
|
||||
event_type=ContextEventType.LLM_RESPONSE,
|
||||
timestamp=time.time(),
|
||||
session_id=self._session_id,
|
||||
agent_name=agent_name,
|
||||
data={
|
||||
"response_tokens": response_tokens,
|
||||
"duration_ms": duration_ms,
|
||||
"finish_reason": finish_reason,
|
||||
},
|
||||
))
|
||||
|
||||
def context_snapshot(
|
||||
self,
|
||||
agent_name: str,
|
||||
messages_count: int,
|
||||
tokens_used: int,
|
||||
tokens_budget: int,
|
||||
messages: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> None:
|
||||
"""Emit context snapshot event with full context state."""
|
||||
self._emit(ContextEvent(
|
||||
event_type=ContextEventType.CONTEXT_SNAPSHOT,
|
||||
timestamp=time.time(),
|
||||
session_id=self._session_id,
|
||||
agent_name=agent_name,
|
||||
messages_count=messages_count,
|
||||
tokens_used=tokens_used,
|
||||
tokens_budget=tokens_budget,
|
||||
data={
|
||||
"messages": messages or [],
|
||||
},
|
||||
))
|
||||
|
||||
def agent_handoff(
|
||||
self,
|
||||
from_agent: str,
|
||||
to_agent: str,
|
||||
reason: Optional[str] = None,
|
||||
context_passed: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Emit agent handoff event for tracking agent-to-agent flow."""
|
||||
self._emit(ContextEvent(
|
||||
event_type=ContextEventType.AGENT_HANDOFF,
|
||||
timestamp=time.time(),
|
||||
session_id=self._session_id,
|
||||
agent_name=from_agent,
|
||||
data={
|
||||
"from_agent": from_agent,
|
||||
"to_agent": to_agent,
|
||||
"reason": reason,
|
||||
"context_passed": context_passed or {},
|
||||
},
|
||||
))
|
||||
|
||||
def flush(self) -> None:
|
||||
"""Flush the sink."""
|
||||
self._sink.flush()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the sink."""
|
||||
self._sink.close()
|
||||
|
||||
@property
|
||||
def session_id(self) -> str:
|
||||
"""Get the session ID."""
|
||||
return self._session_id
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""Check if emitter is enabled."""
|
||||
return self._enabled
|
||||
|
||||
@enabled.setter
|
||||
def enabled(self, value: bool) -> None:
|
||||
"""Set enabled state."""
|
||||
self._enabled = value
|
||||
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
Utils Module for PraisonAI Agents.
|
||||
|
||||
Provides shared utilities used across the SDK:
|
||||
- Variable providers: Dynamic variable substitution ({{today}}, {{now}}, etc.)
|
||||
- Common helpers: Shared functions for Agent, Workflow, and other modules
|
||||
|
||||
All imports are lazy-loaded for zero performance impact.
|
||||
"""
|
||||
|
||||
from .variables import (
|
||||
# Protocol
|
||||
DynamicVariableProvider,
|
||||
# Built-in providers
|
||||
NowProvider,
|
||||
TodayProvider,
|
||||
DateProvider,
|
||||
TimestampProvider,
|
||||
UUIDProvider,
|
||||
YearProvider,
|
||||
MonthProvider,
|
||||
# Registry
|
||||
VariableProviderRegistry,
|
||||
get_provider_registry,
|
||||
register_variable_provider,
|
||||
resolve_dynamic_variable,
|
||||
# Shared utility
|
||||
substitute_variables,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Protocol
|
||||
"DynamicVariableProvider",
|
||||
# Built-in providers
|
||||
"NowProvider",
|
||||
"TodayProvider",
|
||||
"DateProvider",
|
||||
"TimestampProvider",
|
||||
"UUIDProvider",
|
||||
"YearProvider",
|
||||
"MonthProvider",
|
||||
# Registry
|
||||
"VariableProviderRegistry",
|
||||
"get_provider_registry",
|
||||
"register_variable_provider",
|
||||
"resolve_dynamic_variable",
|
||||
# Shared utility
|
||||
"substitute_variables",
|
||||
]
|
||||
@@ -0,0 +1,283 @@
|
||||
"""
|
||||
Dynamic Variable Providers for PraisonAI Workflows.
|
||||
|
||||
Provides protocol-driven dynamic variable substitution for {{variable}} placeholders
|
||||
that resolve at runtime rather than requiring static values.
|
||||
|
||||
Built-in providers:
|
||||
- now: Current datetime (ISO format)
|
||||
- today: Current date (human-readable)
|
||||
- date: Current date (YYYY-MM-DD)
|
||||
- uuid: Random UUID
|
||||
- timestamp: Unix timestamp
|
||||
|
||||
Usage in YAML:
|
||||
variables:
|
||||
current_date: "{{today}}"
|
||||
topic: "Latest AI news for {{today}}"
|
||||
|
||||
Usage in Python:
|
||||
from praisonaiagents.workflows.variable_providers import get_provider_registry
|
||||
|
||||
registry = get_provider_registry()
|
||||
registry.register("custom", MyCustomProvider())
|
||||
"""
|
||||
|
||||
import threading
|
||||
import uuid as uuid_module
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import date, datetime
|
||||
from typing import Any, Callable, Dict, Optional, Union
|
||||
|
||||
|
||||
class DynamicVariableProvider(ABC):
|
||||
"""
|
||||
Protocol for dynamic variable providers.
|
||||
|
||||
Implement this class to create custom dynamic variables
|
||||
that resolve at runtime.
|
||||
|
||||
Example:
|
||||
class CounterProvider(DynamicVariableProvider):
|
||||
def __init__(self):
|
||||
self._count = 0
|
||||
|
||||
def get_value(self) -> int:
|
||||
self._count += 1
|
||||
return self._count
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_value(self) -> Any:
|
||||
"""Get the current value of this dynamic variable."""
|
||||
...
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Built-in Providers
|
||||
# =============================================================================
|
||||
|
||||
class NowProvider(DynamicVariableProvider):
|
||||
"""Returns current datetime in ISO format."""
|
||||
|
||||
def get_value(self) -> str:
|
||||
return datetime.now().isoformat()
|
||||
|
||||
|
||||
class TodayProvider(DynamicVariableProvider):
|
||||
"""Returns current date in human-readable format (e.g., 'January 19, 2026')."""
|
||||
|
||||
def get_value(self) -> str:
|
||||
return date.today().strftime("%B %d, %Y")
|
||||
|
||||
|
||||
class DateProvider(DynamicVariableProvider):
|
||||
"""Returns current date in YYYY-MM-DD format."""
|
||||
|
||||
def get_value(self) -> str:
|
||||
return date.today().strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
class TimestampProvider(DynamicVariableProvider):
|
||||
"""Returns current Unix timestamp."""
|
||||
|
||||
def get_value(self) -> int:
|
||||
return int(datetime.now().timestamp())
|
||||
|
||||
|
||||
class UUIDProvider(DynamicVariableProvider):
|
||||
"""Returns a new random UUID."""
|
||||
|
||||
def get_value(self) -> str:
|
||||
return str(uuid_module.uuid4())
|
||||
|
||||
|
||||
class YearProvider(DynamicVariableProvider):
|
||||
"""Returns current year."""
|
||||
|
||||
def get_value(self) -> int:
|
||||
return date.today().year
|
||||
|
||||
|
||||
class MonthProvider(DynamicVariableProvider):
|
||||
"""Returns current month name."""
|
||||
|
||||
def get_value(self) -> str:
|
||||
return date.today().strftime("%B")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Provider Registry
|
||||
# =============================================================================
|
||||
|
||||
class VariableProviderRegistry:
|
||||
"""
|
||||
Central registry for dynamic variable providers.
|
||||
|
||||
Thread-safe for multi-agent scenarios.
|
||||
|
||||
Usage:
|
||||
registry = get_provider_registry()
|
||||
value = registry.resolve("today") # "January 19, 2026"
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._providers: Dict[str, DynamicVariableProvider] = {}
|
||||
self._functions: Dict[str, Callable[[], Any]] = {}
|
||||
self._lock = threading.RLock()
|
||||
self._register_builtins()
|
||||
|
||||
def _register_builtins(self) -> None:
|
||||
"""Register built-in providers."""
|
||||
self._providers["now"] = NowProvider()
|
||||
self._providers["today"] = TodayProvider()
|
||||
self._providers["date"] = DateProvider()
|
||||
self._providers["timestamp"] = TimestampProvider()
|
||||
self._providers["uuid"] = UUIDProvider()
|
||||
self._providers["year"] = YearProvider()
|
||||
self._providers["month"] = MonthProvider()
|
||||
|
||||
def register(
|
||||
self,
|
||||
name: str,
|
||||
provider: Union[DynamicVariableProvider, Callable[[], Any]],
|
||||
overwrite: bool = False
|
||||
) -> None:
|
||||
"""
|
||||
Register a dynamic variable provider.
|
||||
|
||||
Args:
|
||||
name: Variable name (used as {{name}})
|
||||
provider: DynamicVariableProvider instance or callable
|
||||
overwrite: If True, overwrite existing provider
|
||||
|
||||
Raises:
|
||||
ValueError: If provider exists and overwrite=False
|
||||
"""
|
||||
with self._lock:
|
||||
if name in self._providers or name in self._functions:
|
||||
if not overwrite:
|
||||
raise ValueError(f"Provider '{name}' already registered")
|
||||
|
||||
if isinstance(provider, DynamicVariableProvider):
|
||||
self._providers[name] = provider
|
||||
elif callable(provider):
|
||||
self._functions[name] = provider
|
||||
else:
|
||||
raise TypeError(f"Expected DynamicVariableProvider or callable, got {type(provider)}")
|
||||
|
||||
def unregister(self, name: str) -> bool:
|
||||
"""Remove a provider."""
|
||||
with self._lock:
|
||||
if name in self._providers:
|
||||
del self._providers[name]
|
||||
return True
|
||||
if name in self._functions:
|
||||
del self._functions[name]
|
||||
return True
|
||||
return False
|
||||
|
||||
def resolve(self, name: str) -> Optional[Any]:
|
||||
"""
|
||||
Resolve a dynamic variable by name.
|
||||
|
||||
Args:
|
||||
name: Variable name
|
||||
|
||||
Returns:
|
||||
Resolved value, or None if not found
|
||||
"""
|
||||
with self._lock:
|
||||
if name in self._providers:
|
||||
return self._providers[name].get_value()
|
||||
if name in self._functions:
|
||||
return self._functions[name]()
|
||||
return None
|
||||
|
||||
def has(self, name: str) -> bool:
|
||||
"""Check if a provider exists."""
|
||||
with self._lock:
|
||||
return name in self._providers or name in self._functions
|
||||
|
||||
def list_providers(self) -> list:
|
||||
"""List all registered provider names."""
|
||||
with self._lock:
|
||||
return list(self._providers.keys()) + list(self._functions.keys())
|
||||
|
||||
def __contains__(self, name: str) -> bool:
|
||||
return self.has(name)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Global Registry
|
||||
# =============================================================================
|
||||
|
||||
_global_registry: Optional[VariableProviderRegistry] = None
|
||||
|
||||
|
||||
def get_provider_registry() -> VariableProviderRegistry:
|
||||
"""Get the global variable provider registry."""
|
||||
global _global_registry
|
||||
if _global_registry is None:
|
||||
_global_registry = VariableProviderRegistry()
|
||||
return _global_registry
|
||||
|
||||
|
||||
def register_variable_provider(
|
||||
name: str,
|
||||
provider: Union[DynamicVariableProvider, Callable[[], Any]]
|
||||
) -> None:
|
||||
"""Convenience function to register a provider with the global registry."""
|
||||
get_provider_registry().register(name, provider)
|
||||
|
||||
|
||||
def resolve_dynamic_variable(name: str) -> Optional[Any]:
|
||||
"""Convenience function to resolve a dynamic variable."""
|
||||
return get_provider_registry().resolve(name)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Shared Variable Substitution Utility (DRY)
|
||||
# =============================================================================
|
||||
|
||||
def substitute_variables(text: str, variables: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Substitute {{variable}} placeholders in text.
|
||||
|
||||
This is the canonical, DRY implementation used by all workflow modules.
|
||||
|
||||
Resolution order:
|
||||
1. Dynamic variable providers ({{now}}, {{today}}, {{uuid}}, etc.)
|
||||
2. Static variables from the provided variables dict
|
||||
3. Keep original placeholder if not found
|
||||
|
||||
Args:
|
||||
text: Text containing {{variable}} placeholders
|
||||
variables: Static variables dict
|
||||
|
||||
Returns:
|
||||
Text with variables substituted
|
||||
|
||||
Example:
|
||||
>>> substitute_variables("Hello {{today}} - {{name}}", {"name": "World"})
|
||||
"Hello January 19, 2026 - World"
|
||||
"""
|
||||
import re
|
||||
|
||||
provider_registry = get_provider_registry()
|
||||
|
||||
def replace(match):
|
||||
var_name = match.group(1).strip()
|
||||
|
||||
# First check dynamic providers
|
||||
if provider_registry.has(var_name):
|
||||
return str(provider_registry.resolve(var_name))
|
||||
|
||||
# Then check static variables
|
||||
if var_name in variables:
|
||||
return str(variables[var_name])
|
||||
|
||||
# Keep original if not found
|
||||
return match.group(0)
|
||||
|
||||
return re.sub(r'\{\{(\w+)\}\}', replace, text)
|
||||
@@ -18,12 +18,14 @@ from .workflows import (
|
||||
Parallel,
|
||||
Loop,
|
||||
Repeat,
|
||||
Include,
|
||||
|
||||
# Convenience functions
|
||||
route,
|
||||
parallel,
|
||||
loop,
|
||||
repeat,
|
||||
include,
|
||||
|
||||
# Backward compatibility aliases
|
||||
StepInput,
|
||||
@@ -74,10 +76,12 @@ __all__ = [
|
||||
"Parallel",
|
||||
"Loop",
|
||||
"Repeat",
|
||||
"Include",
|
||||
"route",
|
||||
"parallel",
|
||||
"loop",
|
||||
"repeat",
|
||||
"include",
|
||||
|
||||
# Workflow Config Classes
|
||||
"WorkflowOutputConfig",
|
||||
@@ -109,3 +113,4 @@ __all__ = [
|
||||
"StepInput",
|
||||
"StepOutput",
|
||||
]
|
||||
|
||||
@@ -48,25 +48,10 @@ class WorkflowOutputPreset(str, Enum):
|
||||
DEBUG = "debug"
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkflowOutputConfig:
|
||||
"""
|
||||
Configuration for workflow output behavior.
|
||||
|
||||
Consolidates: verbose, stream
|
||||
|
||||
Usage:
|
||||
# Preset (string)
|
||||
Workflow(steps=[...], output="verbose")
|
||||
|
||||
# Config
|
||||
Workflow(steps=[...], output=WorkflowOutputConfig(verbose=True, stream=True))
|
||||
"""
|
||||
verbose: bool = False
|
||||
stream: bool = True
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"verbose": self.verbose, "stream": self.stream}
|
||||
# WorkflowOutputConfig is now an alias to OutputConfig for DRY approach
|
||||
# Workflows use the same output configuration as Agent
|
||||
# Import OutputConfig and alias it for backward compatibility
|
||||
from ..config.feature_configs import OutputConfig as WorkflowOutputConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -319,23 +304,26 @@ def resolve_param(
|
||||
)
|
||||
|
||||
|
||||
def resolve_output_config(value: Any) -> WorkflowOutputConfig:
|
||||
"""Resolve output parameter to WorkflowOutputConfig using canonical resolver.
|
||||
def resolve_output_config(value: Any):
|
||||
"""Resolve output parameter using canonical OUTPUT_PRESETS (DRY approach).
|
||||
|
||||
Supports: None, str preset, list [preset, overrides], Config, dict
|
||||
Supports: None, str preset, list [preset, overrides], OutputConfig, dict
|
||||
|
||||
Uses the same OUTPUT_PRESETS as Agent for consistency.
|
||||
"""
|
||||
from ..config.param_resolver import resolve, ArrayMode
|
||||
from ..config.presets import WORKFLOW_OUTPUT_PRESETS
|
||||
from ..config.presets import OUTPUT_PRESETS
|
||||
from ..config.feature_configs import OutputConfig
|
||||
|
||||
result = resolve(
|
||||
value=value,
|
||||
param_name="output",
|
||||
config_class=WorkflowOutputConfig,
|
||||
presets=WORKFLOW_OUTPUT_PRESETS,
|
||||
config_class=OutputConfig,
|
||||
presets=OUTPUT_PRESETS,
|
||||
array_mode=ArrayMode.PRESET_OVERRIDE,
|
||||
default=WorkflowOutputConfig(),
|
||||
default=OutputConfig(), # Default is silent mode
|
||||
)
|
||||
return result if result else WorkflowOutputConfig(verbose=False, stream=False)
|
||||
return result if result else OutputConfig()
|
||||
|
||||
|
||||
def resolve_planning_config(value: Any) -> Optional[WorkflowPlanningConfig]:
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -14,7 +14,7 @@ from typing import Any, Callable, Dict, List, Optional, Union
|
||||
from pathlib import Path
|
||||
|
||||
from ..agent.agent import Agent
|
||||
from .workflows import Workflow, WorkflowStep, route, parallel, loop, repeat
|
||||
from .workflows import Workflow, WorkflowStep, route, parallel, loop, repeat, Include, include
|
||||
|
||||
|
||||
class YAMLWorkflowParser:
|
||||
@@ -150,6 +150,19 @@ class YAMLWorkflowParser:
|
||||
if 'steps' not in normalized:
|
||||
normalized['steps'] = self._extract_steps_from_roles(normalized['roles'])
|
||||
|
||||
# 3.5. Handle 'includes:' section - append include steps after role-based steps
|
||||
# This allows agents.yaml files to include other recipes as final steps
|
||||
if 'includes' in normalized:
|
||||
if 'steps' not in normalized:
|
||||
normalized['steps'] = []
|
||||
for include_item in normalized['includes']:
|
||||
if isinstance(include_item, str):
|
||||
# Simple include: just recipe name
|
||||
normalized['steps'].append({'include': include_item})
|
||||
elif isinstance(include_item, dict):
|
||||
# Include with configuration
|
||||
normalized['steps'].append({'include': include_item})
|
||||
|
||||
# 4. Normalize agent fields: backstory -> instructions (for agents section)
|
||||
if 'agents' in normalized:
|
||||
for agent_id, agent_config in normalized['agents'].items():
|
||||
@@ -236,10 +249,21 @@ class YAMLWorkflowParser:
|
||||
reasoning = workflow_config.get('reasoning', False)
|
||||
verbose = workflow_config.get('verbose', False)
|
||||
memory_config = workflow_config.get('memory_config')
|
||||
output_mode = workflow_config.get('output') # NEW: parse output mode from YAML
|
||||
|
||||
# Parse variables
|
||||
variables = data.get('variables', {})
|
||||
|
||||
# Auto-add 'topic' to variables for include recipe propagation
|
||||
# This ensures included recipes receive the parent's topic via {{topic}}
|
||||
topic_value = data.get('topic')
|
||||
if topic_value and 'topic' not in variables:
|
||||
# Substitute template variables (e.g., {{today}}) in topic if present
|
||||
if "{{" in str(topic_value):
|
||||
from praisonaiagents.utils.variables import substitute_variables
|
||||
topic_value = substitute_variables(str(topic_value), variables)
|
||||
variables['topic'] = topic_value
|
||||
|
||||
# Parse agents - support both 'agents' and 'roles' keys for backward compatibility
|
||||
agents_data = data.get('agents', {})
|
||||
if not agents_data and 'roles' in data:
|
||||
@@ -262,12 +286,56 @@ class YAMLWorkflowParser:
|
||||
from .workflow_configs import WorkflowPlanningConfig
|
||||
planning_value = WorkflowPlanningConfig(enabled=bool(planning), llm=planning_llm, reasoning=reasoning)
|
||||
|
||||
# Determine output mode: explicit output > verbose flag > default
|
||||
workflow_output = output_mode
|
||||
if workflow_output is None and verbose:
|
||||
workflow_output = "verbose"
|
||||
|
||||
# Build memory config if specified - pass raw dict for flexibility
|
||||
memory_value = memory_config if memory_config else None
|
||||
|
||||
# Parse context management config (CRITICAL for token overflow prevention)
|
||||
# Supports: context: true OR context: {auto_compact: true, strategy: smart, ...}
|
||||
# Also supports YAML-friendly aliases: enabled -> auto_compact, max_tool_output_tokens -> tool_output_max
|
||||
context_config = data.get('context')
|
||||
if context_config is True:
|
||||
# Simple enable: context: true
|
||||
context_value = True
|
||||
elif isinstance(context_config, dict):
|
||||
# Detailed config: context: {auto_compact: true, ...}
|
||||
# Apply field aliasing for YAML-friendly names
|
||||
normalized_config = context_config.copy()
|
||||
|
||||
# Alias: enabled -> auto_compact
|
||||
if 'enabled' in normalized_config:
|
||||
normalized_config['auto_compact'] = normalized_config.pop('enabled')
|
||||
|
||||
# Alias: max_tool_output_tokens -> tool_output_max
|
||||
if 'max_tool_output_tokens' in normalized_config:
|
||||
normalized_config['tool_output_max'] = normalized_config.pop('max_tool_output_tokens')
|
||||
|
||||
# Alias: threshold -> compact_threshold
|
||||
if 'threshold' in normalized_config:
|
||||
normalized_config['compact_threshold'] = normalized_config.pop('threshold')
|
||||
|
||||
try:
|
||||
from ..context.models import ContextConfig
|
||||
context_value = ContextConfig(**normalized_config)
|
||||
except Exception:
|
||||
# Fallback: just enable with True if ContextConfig fails
|
||||
context_value = True
|
||||
else:
|
||||
context_value = None
|
||||
|
||||
workflow = Workflow(
|
||||
name=name,
|
||||
steps=steps,
|
||||
variables=variables,
|
||||
planning=planning_value,
|
||||
default_llm=default_llm,
|
||||
output=workflow_output, # Pass output mode to Workflow
|
||||
memory=memory_value, # Pass memory config to Workflow
|
||||
context=context_value, # Pass context management config to Workflow
|
||||
)
|
||||
|
||||
# Store additional attributes for feature parity with agents.yaml
|
||||
@@ -278,7 +346,14 @@ class YAMLWorkflowParser:
|
||||
|
||||
# Store workflow input (from 'input' or 'topic' field)
|
||||
# This is the default input passed to workflow.start() if no input is provided
|
||||
workflow.default_input = data.get('input', data.get('topic', ''))
|
||||
default_input = data.get('input', data.get('topic', ''))
|
||||
|
||||
# Substitute dynamic variables ({{today}}, {{now}}, etc.) in default_input
|
||||
if default_input and "{{" in default_input:
|
||||
from praisonaiagents.utils.variables import substitute_variables
|
||||
default_input = substitute_variables(default_input, {})
|
||||
|
||||
workflow.default_input = default_input
|
||||
|
||||
return workflow
|
||||
|
||||
@@ -500,6 +575,8 @@ class YAMLWorkflowParser:
|
||||
return self._parse_loop_step(step_data)
|
||||
elif 'repeat' in step_data:
|
||||
return self._parse_repeat_step(step_data)
|
||||
elif 'include' in step_data:
|
||||
return self._parse_include_step(step_data)
|
||||
elif 'agent' in step_data:
|
||||
return self._parse_agent_step(step_data)
|
||||
else:
|
||||
@@ -582,10 +659,47 @@ class YAMLWorkflowParser:
|
||||
if async_execution is not None:
|
||||
agent._yaml_async_execution = async_execution
|
||||
|
||||
# Handle output_variable (store output in named variable for loop iteration)
|
||||
output_variable = step_data.get('output_variable')
|
||||
if output_variable:
|
||||
agent._yaml_output_variable = output_variable
|
||||
|
||||
return agent
|
||||
else:
|
||||
raise ValueError(f"Agent '{agent_id}' not defined in agents section")
|
||||
|
||||
def _parse_include_step(self, step_data: Dict) -> Include:
|
||||
"""
|
||||
Parse an include pattern step.
|
||||
|
||||
Args:
|
||||
step_data: Step definition with 'include' key
|
||||
|
||||
Returns:
|
||||
Include pattern object
|
||||
|
||||
YAML syntax:
|
||||
# Simple include
|
||||
- include: wordpress-publisher
|
||||
|
||||
# Include with configuration
|
||||
- include:
|
||||
recipe: wordpress-publisher
|
||||
input: "{{previous_output}}"
|
||||
"""
|
||||
include_config = step_data['include']
|
||||
|
||||
if isinstance(include_config, str):
|
||||
# Simple form: include: recipe-name
|
||||
recipe_name = include_config
|
||||
input_template = None
|
||||
else:
|
||||
# Config form: include: {recipe: ..., input: ...}
|
||||
recipe_name = include_config.get('recipe', '')
|
||||
input_template = include_config.get('input')
|
||||
|
||||
return Include(recipe=recipe_name, input=input_template)
|
||||
|
||||
def _parse_route_step(self, step_data: Dict) -> Dict:
|
||||
"""
|
||||
Parse a route pattern step.
|
||||
@@ -635,7 +749,7 @@ class YAMLWorkflowParser:
|
||||
|
||||
return parallel(parallel_steps)
|
||||
|
||||
def _parse_loop_step(self, step_data: Dict) -> Dict:
|
||||
def _parse_loop_step(self, step_data: Dict):
|
||||
"""
|
||||
Parse a loop pattern step.
|
||||
|
||||
@@ -644,24 +758,144 @@ class YAMLWorkflowParser:
|
||||
|
||||
Returns:
|
||||
Loop pattern object
|
||||
|
||||
Supports multiple syntax forms:
|
||||
# Simple form with agent at step level
|
||||
- loop:
|
||||
over: items
|
||||
agent: processor
|
||||
|
||||
# Nested step form
|
||||
- loop:
|
||||
over: items
|
||||
parallel: true
|
||||
step:
|
||||
agent: processor
|
||||
|
||||
# Full nested form
|
||||
- loop:
|
||||
over: items
|
||||
parallel: true
|
||||
max_workers: 4
|
||||
step: processor
|
||||
|
||||
# Multi-step form (NEW) - steps at step_data level
|
||||
- loop:
|
||||
over: items
|
||||
parallel: true
|
||||
steps:
|
||||
- agent: researcher
|
||||
- agent: writer
|
||||
|
||||
# Multi-step form (NEW) - steps inside loop config
|
||||
- loop:
|
||||
over: items
|
||||
parallel: true
|
||||
steps:
|
||||
- agent: researcher
|
||||
- agent: writer
|
||||
"""
|
||||
loop_config = step_data['loop']
|
||||
agent_id = step_data.get('agent')
|
||||
|
||||
if agent_id and agent_id in self._agents:
|
||||
agent = self._agents[agent_id]
|
||||
# Get loop parameters
|
||||
over = loop_config.get('over')
|
||||
from_csv = loop_config.get('from_csv')
|
||||
from_file = loop_config.get('from_file')
|
||||
var_name = loop_config.get('var_name', 'item')
|
||||
parallel_flag = loop_config.get('parallel', False)
|
||||
max_workers_raw = loop_config.get('max_workers')
|
||||
# Ensure max_workers is int (YAML may parse as string or contain template)
|
||||
max_workers = None
|
||||
if max_workers_raw is not None:
|
||||
try:
|
||||
max_workers = int(max_workers_raw)
|
||||
except (ValueError, TypeError):
|
||||
# If it's a template variable like {{max_workers}}, leave as None
|
||||
max_workers = None
|
||||
|
||||
# Check for multi-step form (NEW) - steps: at step_data level or inside loop config
|
||||
nested_steps = step_data.get('steps') or loop_config.get('steps')
|
||||
if nested_steps and isinstance(nested_steps, list) and len(nested_steps) > 0:
|
||||
# Parse each nested step
|
||||
parsed_steps = []
|
||||
for nested_step_data in nested_steps:
|
||||
parsed_step = self._parse_single_step(nested_step_data)
|
||||
if parsed_step is not None:
|
||||
parsed_steps.append(parsed_step)
|
||||
|
||||
if parsed_steps:
|
||||
# Extract output_variable from step level
|
||||
output_variable = step_data.get('output_variable')
|
||||
|
||||
return loop(
|
||||
steps=parsed_steps,
|
||||
over=over,
|
||||
from_csv=from_csv,
|
||||
from_file=from_file,
|
||||
var_name=var_name,
|
||||
parallel=parallel_flag,
|
||||
max_workers=max_workers,
|
||||
output_variable=output_variable
|
||||
)
|
||||
|
||||
# Resolve the step/agent to execute (single step - backward compat)
|
||||
agent_or_step = None
|
||||
|
||||
# First check for agent at step level (original syntax)
|
||||
agent_id = step_data.get('agent')
|
||||
if agent_id and agent_id in self._agents:
|
||||
agent_or_step = self._agents[agent_id]
|
||||
# Store action if present
|
||||
if 'action' in step_data:
|
||||
agent._yaml_action = step_data['action']
|
||||
|
||||
# Get loop parameters
|
||||
over = loop_config.get('over')
|
||||
from_csv = loop_config.get('from_csv')
|
||||
|
||||
return loop(agent, over=over, from_csv=from_csv)
|
||||
agent_or_step._yaml_action = step_data['action']
|
||||
|
||||
raise ValueError("Loop step requires an agent")
|
||||
# Check for step: syntax (nested form)
|
||||
if 'step' in step_data:
|
||||
step_def = step_data['step']
|
||||
if isinstance(step_def, dict) and 'agent' in step_def:
|
||||
agent_id = step_def['agent']
|
||||
if agent_id in self._agents:
|
||||
agent_or_step = self._agents[agent_id]
|
||||
if 'action' in step_def:
|
||||
agent_or_step._yaml_action = step_def['action']
|
||||
elif isinstance(step_def, str):
|
||||
# step: agent_name shorthand
|
||||
if step_def in self._agents:
|
||||
agent_or_step = self._agents[step_def]
|
||||
|
||||
# Check for step inside loop config
|
||||
if 'step' in loop_config:
|
||||
step_def = loop_config['step']
|
||||
if isinstance(step_def, str) and step_def in self._agents:
|
||||
agent_or_step = self._agents[step_def]
|
||||
|
||||
# Check for include at step level (include inside loop)
|
||||
if agent_or_step is None and 'include' in step_data:
|
||||
include_value = step_data['include']
|
||||
if isinstance(include_value, str):
|
||||
agent_or_step = Include(recipe=include_value)
|
||||
elif isinstance(include_value, dict):
|
||||
agent_or_step = Include(
|
||||
recipe=include_value.get('recipe', ''),
|
||||
input=include_value.get('input')
|
||||
)
|
||||
|
||||
if agent_or_step is None:
|
||||
raise ValueError("Loop step requires an agent, include, or steps")
|
||||
|
||||
# Extract output_variable from step level (where user specifies it in YAML)
|
||||
output_variable = step_data.get('output_variable')
|
||||
|
||||
return loop(
|
||||
step=agent_or_step,
|
||||
over=over,
|
||||
from_csv=from_csv,
|
||||
from_file=from_file,
|
||||
var_name=var_name,
|
||||
parallel=parallel_flag,
|
||||
max_workers=max_workers,
|
||||
output_variable=output_variable
|
||||
)
|
||||
|
||||
def _parse_repeat_step(self, step_data: Dict) -> Dict:
|
||||
"""
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "0.12.3"
|
||||
version = "0.13.2"
|
||||
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
Loaded 100 of 235 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user