diff --git a/Docs/Book.pt-br/16-ai-agents/README.md b/Docs/Book.pt-br/16-ai-agents/README.md new file mode 100644 index 00000000..0388edc0 --- /dev/null +++ b/Docs/Book.pt-br/16-ai-agents/README.md @@ -0,0 +1,251 @@ +# 🤖 Agentes de IA & Orquestração (Dext.AI.Agent + Dext.AI.Graph) + +O Dext traz duas camadas de orquestração construídas sobre o [Dext MCP](../15-mcp-server/README.md), para quem quer o modelo mental do LangChain/LangGraph em Delphi nativo, sem nenhuma dependência externa além da RTL: + +- **`Dext.AI.Agent`** — um loop ReAct de agente único (pense no `AgentExecutor` do **LangChain**): um provider, um conjunto de tools, uma conversa. +- **`Dext.AI.Graph`** — orquestração via grafo por cima disso (pense no `StateGraph` do **LangGraph**): nós e edges explícitos, roteamento condicional, estado com checkpoint entre turnos, aprovação humana (human-in-the-loop) e composição de sub-agentes. + +Use `Dext.AI.Agent` quando um loop simples "chama o LLM, executa tools, repete até terminar" já resolve. Use `Dext.AI.Graph` quando precisar de mais de uma etapa distinta (um roteador, uma etapa de revisão, vários agentes cooperando) ou precisar pausar e retomar entre turnos/processos. + +As duas camadas usam diretamente o padrão `TMCPToolProvider` / `[MCPTool]` / `[MCPParam]` do [capítulo de MCP](../15-mcp-server/README.md) — as mesmas tools declarativas que você exporia para um cliente de IA externo podem ser reaproveitadas como o conjunto de tools do próprio agente. + +--- + +## 📦 Onde fica cada coisa + +``` +Sources/AI/Agent/ +├── Dext.AI.Agent.Contracts.pas ILLMProvider, TLLMMessage, TAgentConfig, IAgentObserver +├── Dext.AI.Agent.Factory.pas TLLMFactory.CreateProvider(config) +├── Dext.AI.Agent.Runner.pas TAgentRunner (loop ReAct de agente único) +├── Dext.AI.Agent.Observer.pas IAgentObserver, TConsoleObserver +└── Providers/ + ├── Dext.AI.Agent.Provider.OpenAI.pas + ├── Dext.AI.Agent.Provider.Anthropic.pas + └── Dext.AI.Agent.Provider.Ollama.pas + +Sources/AI/Graph/ +├── Dext.AI.Graph.Contracts.pas ICompiledAgent, ICheckpointer, TNodeHandler, TNodeContext +├── Dext.AI.Graph.State.pas TAgentState (imutável) +├── Dext.AI.Graph.Edge.pas TEdge, TEdgeRoute, TEdgeCondition +├── Dext.AI.Graph.Graph.pas TAgentGraph (o StateGraph) +├── Dext.AI.Graph.Compiled.pas TCompiledAgent (Run/Resume/Cancel/GetState/AsNode) +├── Dext.AI.Graph.Checkpointer.pas TMemoryCheckpointer, TFileCheckpointer +└── Nodes/ + ├── Dext.AI.Graph.Node.LLM.pas TLLMNode (nó padrão que chama o LLM) + └── Dext.AI.Graph.Node.Tools.pas TToolsNode (nó padrão que executa tools) +``` + +--- + +## 🚀 Dext.AI.Agent — Início Rápido + +```pascal +uses + Dext.AI.Agent.Contracts, + Dext.AI.Agent.Factory, + Dext.AI.Agent.Observer, + Dext.AI.MCP.Tools; + +var + Config: TAgentConfig; + Provider: ILLMProvider; + Runner: TAgentRunner; + Result: TAgentResult; +begin + Config := TAgentConfig.OpenAI('gpt-4o'); + Config.ApiKey := GetEnvironmentVariable('OPENAI_API_KEY'); + Config.SystemPrompt := 'Você é um assistente útil. Use as tools para responder com precisão.'; + + Provider := TLLMFactory.CreateProvider(Config); + + Runner := TAgentRunner.Create(Provider, Config, TConsoleObserver.Create); + try + Runner.RegisterProvider(TMyToolProvider.Create); // qualquer TMCPToolProvider + Result := Runner.Run('Quantos arquivos .pas existem em Sources/AI/Graph?'); + Writeln(Result.FinalAnswer); + finally + Runner.Free; + end; +end; +``` + +`TAgentConfig` tem construtores de fábrica para os três providers nativos: + +```pascal +TAgentConfig.OpenAI('gpt-4o'); +TAgentConfig.Anthropic('claude-sonnet-4-6'); +TAgentConfig.Ollama('llama3.2'); // BaseUrl padrão: http://localhost:11434 +``` + +`TLLMFactory.CreateProvider(Config)` lê `Config.ProviderString` (`'openai:gpt-4o'`, `'anthropic:...'`, `'ollama:...'`) e devolve o `ILLMProvider` correspondente. `ILLMProvider` é uma interface de estratégia pequena — implemente a sua para qualquer provider que não venha pronto: + +```pascal +ILLMProvider = interface + function Complete(const AMessages: TArray; + const ATools: TArray): TLLMResponse; + function ProviderName: string; + function ModelName: string; +end; +``` + +`IAgentObserver` é chamado a cada passo do loop ReAct (`OnIterationStart`, `OnToolCall`, `OnToolResult`, `OnLLMResponse`, `OnFinished`) — implemente o seu próprio para transmitir o progresso para uma UI, um arquivo de log ou um endpoint SSE, em vez do `TConsoleObserver` (que escreve no console). + +**Exemplo completo funcionando:** [Examples/AI/AgentDemo](../../Examples/AI/AgentDemo/) + +--- + +## 🕸️ Dext.AI.Graph — Início Rápido + +A API do grafo espelha o `StateGraph` do LangGraph quase um para um: + +```pascal +LangGraph (Python) Dext.AI.Graph (Delphi) +────────────────────────── ──────────────────────────────── +StateGraph(State) → TAgentGraph.Create +graph.add_node(name, fn) → Graph.AddNode(name, Handler) +graph.set_entry_point(name) → Graph.SetEntryPoint(name) +graph.add_edge(a, b) → Graph.AddEdge(a, b) +graph.add_conditional_edges → Graph.AddConditionalEdge(from, cond, routes) +START / END → GRAPH_START / GRAPH_END +graph.compile() → Graph.Compile(Provider, Config, Observer, Checkpointer) +compiled.invoke(input) → Agent.Run(input, threadId) +MemorySaver → TMemoryCheckpointer +interrupt_before → InterruptBefore([...]) / .RequireApproval(node) +Command(resume=...) → Agent.Resume(threadId) +Subgraphs → ICompiledAgent.AsNode +``` + +Um grafo ReAct mínimo — `call_llm` chama o modelo, roteia para `execute_tools` se ele pediu uma tool, volta em loop, e para quando o modelo tem uma resposta final: + +```pascal +uses + Dext.AI.Graph.Contracts, Dext.AI.Graph.State, Dext.AI.Graph.Edge, + Dext.AI.Graph.Graph, Dext.AI.Graph.Compiled, Dext.AI.Graph.Checkpointer, + Dext.AI.Graph.Node.LLM, Dext.AI.Graph.Node.Tools; + +var + ToolsNode: TToolsNode; + LLMNode: TLLMNode; + Graph: TAgentGraph; + Agent: ICompiledAgent; + Result: TGraphRunResult; +begin + ToolsNode := TToolsNode.Create; + ToolsNode.RegisterProvider(TMyToolProvider.Create); + LLMNode := TLLMNode.Create(ToolsNode.GetToolSchemas); + + Graph := TAgentGraph.Create; + try + Agent := Graph + .AddNode('call_llm', LLMNode.AsHandler) + .AddNode('execute_tools', ToolsNode.AsHandler) + .SetEntryPoint('call_llm') + .AddConditionalEdge('call_llm', DefaultShouldContinue, + [TEdgeRoute.To_('execute_tools'), TEdgeRoute.ToEnd]) + .AddEdge('execute_tools', 'call_llm') + .Compile(Provider, Config, Observer, TMemoryCheckpointer.Create); + finally + Graph.Free; // TCompiledAgent copia nós/edges — seguro liberar logo após o Compile + end; + + Result := Agent.Run('Quantos arquivos .pas existem em Sources/AI/Graph?', 'thread-1'); + Writeln(Result.FinalAnswer); +end; +``` + +**Exemplo completo (todos os recursos abaixo ligados juntos):** [Examples/AI/GraphDemo](../../Examples/AI/GraphDemo/) + +### Conceitos principais + +| Tipo | Papel | +|---|---| +| `TAgentState` | Estado imutável que flui pelo grafo — mensagens, tool calls pendentes, nó atual, contador de iteração, metadata. Todo método `With*` devolve uma **nova** instância; nada é alterado no lugar. | +| `TNodeHandler` | `reference to function(const AState: TAgentState; const ACtx: TNodeContext): TAgentState` — um nó é só uma função de estado para estado. | +| `TEdge` | Fixa (`AddEdge`) ou condicional (`AddConditionalEdge`, guiada por um `TEdgeCondition` que inspeciona o estado e devolve o nome do próximo nó). | +| `ICompiledAgent` | O resultado do `Compile()` — `Run`, `Resume`, `Cancel`, `GetState`, `AsNode`. | +| `ICheckpointer` | Persiste o `TAgentState` de uma thread (como JSON) para que a mesma `AThreadId` possa ser retomada entre chamadas de `Run` — e, com `TFileCheckpointer`, entre reinícios do processo. | + +### Human-in-the-loop (aprovação humana) + +Marque um nó como exigindo aprovação — o grafo pausa **antes** de executá-lo e devolve `grsWaitingApproval` em vez de rodar: + +```pascal +Graph.RequireApproval('execute_tools'); +// equivalente: Graph.InterruptBefore(['execute_tools']); +``` + +```pascal +Result := Agent.Run(Input, ThreadId); +if Result.Status = grsWaitingApproval then +begin + Writeln('Nó pendente: ' + Result.PendingNode); + if UsuarioAprovou then + Result := Agent.Resume(ThreadId) + else + Agent.Cancel(ThreadId); +end; +``` + +`Resume` reexecuta o próprio nó pausado e continua o loop normalmente. `Cancel` apaga o checkpoint da thread. + +### Checkpointing + +```pascal +TMemoryCheckpointer.Create; // local ao processo, some ao sair +TFileCheckpointer.Create; // arquivos JSON em %TEMP%\dext-ai-graph +TFileCheckpointer.Create('C:\MeuCaminho'); // ou um caminho à sua escolha +``` + +Os dois implementam a interface `ICheckpointer`, pequena (`Save`/`Load`/`Exists`/`Delete`) — implemente a sua para persistir numa tabela de banco de dados em vez disso. + +### Subgrafos (`AsNode`) + +Um grafo compilado pode ser embutido como um único nó de outro grafo — é assim que se compõem sub-agentes independentes e testáveis separadamente (um agente "Fiscal" dentro de um grafo "ERP" maior, por exemplo), em vez de achatar os nós de cada sub-agente num único grafo gigante: + +```pascal +FiscalAgent := FiscalGraph.AddNode(...).SetEntryPoint(...).Compile(Provider, Config); + +ERPGraph.AddNode('fiscal_agent', FiscalAgent.AsNode); +``` + +`TAgentState` é um único tipo concreto em todos os grafos do Dext.AI.Graph (diferente dos schemas tipados por grafo do LangGraph), então não há etapa de tradução de estado na fronteira do subgrafo — o estado do pai passa direto, o subgrafo roda do seu próprio ponto de entrada até seu próprio `GRAPH_END`, e o estado resultante (incluindo toda mensagem que ele anexou) volta para o pai, que então decide o que acontece depois através das suas próprias edges. + +> **Restrição:** um subgrafo não pode declarar `RequireApproval`/`InterruptBefore` em nenhum nó seu — `AsNode` levanta `EGraphCompileError` imediatamente, em vez de pular a aprovação silenciosamente. Se alguma parte do fluxo do subgrafo precisar de aprovação humana, coloque `RequireApproval` no nó do grafo **pai** que envolve a chamada do subgrafo. Human-in-the-loop aninhado ainda não é suportado. + +--- + +## 🧭 Vem do LangGraph? O que está coberto, o que não está + +| LangGraph | Dext.AI.Graph | +|---|---| +| `StateGraph` | ✅ `TAgentGraph` | +| funções de nó | ✅ `TNodeHandler` | +| `add_edge` / `add_conditional_edges` | ✅ `AddEdge` / `AddConditionalEdge` | +| `compile()` | ✅ `Compile()` | +| `MemorySaver` | ✅ `TMemoryCheckpointer` / `TFileCheckpointer` | +| multi-turn via `thread_id` | ✅ `AThreadId` | +| `interrupt_before` + resume/cancel | ✅ `RequireApproval` / `InterruptBefore` + `Resume` / `Cancel` | +| Subgrafos | ✅ `ICompiledAgent.AsNode` | +| `recursion_limit` | ✅ `MaxIterations` | +| Schema de estado tipado por grafo | ❌ `TAgentState` é um tipo fixo único; dados extras vão no `Metadata` (string→string) | +| Entry point condicional/dinâmico | ❌ `GRAPH_START` existe só como nome reservado; a entrada é sempre fixa via `SetEntryPoint` | +| `interrupt_after` | ❌ só "antes" é suportado | +| Interrupts dinâmicos (`interrupt()` dentro de um nó) | ❌ interrupts precisam ser declarados estaticamente no grafo | +| `update_state` | ❌ `GetState` é somente leitura; não há como editar o estado pausado antes do `Resume` | +| Human-in-the-loop aninhado | ❌ explicitamente rejeitado pelo `AsNode` (falha alto, não silenciosamente) | +| `get_state_history` / time travel | ❌ o checkpointer guarda só o último estado por thread | +| `Store` (memória de longo prazo entre threads) | ❌ persistência é só por thread | +| `stream_mode` (streaming de primeira classe) | ⚠️ parcial — `IAgentObserver` dá callbacks síncronos, não um stream/generator | +| Fan-out / ramos paralelos (`Send`) | ⚠️ **armadilha**: adicionar mais de um `AddEdge` a partir do mesmo nó de origem não é erro — só a *primeira* é usada, o resto é ignorado silenciosamente. Não há fan-out paralelo automático. | +| Retry policy / cache de resultado por nó | ❌ não implementado | +| `Command` (nó devolve roteamento + atualização de estado juntos) | ❌ o roteamento sempre passa pelas edges | + +Se o seu caso de uso precisar de ramos paralelos de verdade, histórico de estado para auditoria, ou memória entre threads, esses são gaps reais hoje, não só documentação faltando. + +--- + +## 📂 Exemplos + +- **[AgentDemo](../../Examples/AI/AgentDemo/)** — loop ReAct de agente único com tools de sistema de arquivos. +- **[GraphDemo](../../Examples/AI/GraphDemo/)** — todo o conjunto de recursos do grafo ligado junto: roteamento condicional, `RequireApproval` + `Resume`/`Cancel`, persistência via `TFileCheckpointer` entre reinícios, e um subgrafo `polish_agent` embutido via `AsNode`. diff --git a/Docs/Book.pt-br/README.md b/Docs/Book.pt-br/README.md index 1363555e..34784d58 100644 --- a/Docs/Book.pt-br/README.md +++ b/Docs/Book.pt-br/README.md @@ -118,6 +118,11 @@ - [Skills de IA](13-ai-assistants/README.md) - Instruções nativas para integração de IA - [Servidor MCP](15-mcp-server/README.md) - Implementação nativa do Model Context Protocol +#### [16. Agentes de IA & Orquestração](16-ai-agents/README.md) ⭐ NOVO + +- Dext.AI.Agent - Loop ReAct de agente único (estilo LangChain) +- Dext.AI.Graph - Orquestração via grafo, checkpointing, human-in-the-loop, subgrafos (estilo LangGraph) + --- ### Apêndice @@ -148,6 +153,8 @@ Cada capítulo referencia exemplos funcionais do diretório `Examples/` | [Orm.EntityDemo](../../Examples/Orm.EntityDemo/) | ORM Básico | | [Hubs](../../Examples/Hubs/) | SignalR Tempo Real | | [Desktop.MVVM.CustomerCRUD](../../Examples/Desktop.MVVM.CustomerCRUD/) | Navigator, MVVM, Testes | +| [AI.AgentDemo](../../Examples/AI/AgentDemo/) | Dext.AI.Agent, loop ReAct | +| [AI.GraphDemo](../../Examples/AI/GraphDemo/) | Dext.AI.Graph, checkpointing, HITL, subgrafos | --- diff --git a/Docs/Book/16-ai-agents/README.md b/Docs/Book/16-ai-agents/README.md new file mode 100644 index 00000000..a6c23553 --- /dev/null +++ b/Docs/Book/16-ai-agents/README.md @@ -0,0 +1,251 @@ +# 🤖 AI Agents & Orchestration (Dext.AI.Agent + Dext.AI.Graph) + +Dext ships two orchestration layers on top of [Dext MCP](../15-mcp-server/README.md), for teams who want the LangChain/LangGraph mental model in native Delphi, with zero external dependencies beyond the RTL: + +- **`Dext.AI.Agent`** — a single-agent ReAct loop (think **LangChain**'s `AgentExecutor`): one provider, one tool set, one conversation. +- **`Dext.AI.Graph`** — graph-based orchestration on top of it (think **LangGraph**'s `StateGraph`): explicit nodes and edges, conditional routing, checkpointed multi-turn state, human-in-the-loop approval, and sub-agent composition. + +Use `Dext.AI.Agent` when a plain "call the LLM, run tools, repeat until done" loop is enough. Reach for `Dext.AI.Graph` when you need more than one distinct step (a router, a review step, multiple cooperating agents) or you need to pause and resume across turns/processes. + +Both build directly on the `TMCPToolProvider` / `[MCPTool]` / `[MCPParam]` pattern from the [MCP chapter](../15-mcp-server/README.md) — the same declarative tools you'd expose to an external AI client can be reused as an agent's own tool set. + +--- + +## 📦 Where things live + +``` +Sources/AI/Agent/ +├── Dext.AI.Agent.Contracts.pas ILLMProvider, TLLMMessage, TAgentConfig, IAgentObserver +├── Dext.AI.Agent.Factory.pas TLLMFactory.CreateProvider(config) +├── Dext.AI.Agent.Runner.pas TAgentRunner (single-agent ReAct loop) +├── Dext.AI.Agent.Observer.pas IAgentObserver, TConsoleObserver +└── Providers/ + ├── Dext.AI.Agent.Provider.OpenAI.pas + ├── Dext.AI.Agent.Provider.Anthropic.pas + └── Dext.AI.Agent.Provider.Ollama.pas + +Sources/AI/Graph/ +├── Dext.AI.Graph.Contracts.pas ICompiledAgent, ICheckpointer, TNodeHandler, TNodeContext +├── Dext.AI.Graph.State.pas TAgentState (immutable) +├── Dext.AI.Graph.Edge.pas TEdge, TEdgeRoute, TEdgeCondition +├── Dext.AI.Graph.Graph.pas TAgentGraph (the StateGraph) +├── Dext.AI.Graph.Compiled.pas TCompiledAgent (Run/Resume/Cancel/GetState/AsNode) +├── Dext.AI.Graph.Checkpointer.pas TMemoryCheckpointer, TFileCheckpointer +└── Nodes/ + ├── Dext.AI.Graph.Node.LLM.pas TLLMNode (default LLM-calling node) + └── Dext.AI.Graph.Node.Tools.pas TToolsNode (default tool-executing node) +``` + +--- + +## 🚀 Dext.AI.Agent — Quick Start + +```pascal +uses + Dext.AI.Agent.Contracts, + Dext.AI.Agent.Factory, + Dext.AI.Agent.Observer, + Dext.AI.MCP.Tools; + +var + Config: TAgentConfig; + Provider: ILLMProvider; + Runner: TAgentRunner; + Result: TAgentResult; +begin + Config := TAgentConfig.OpenAI('gpt-4o'); + Config.ApiKey := GetEnvironmentVariable('OPENAI_API_KEY'); + Config.SystemPrompt := 'You are a helpful assistant. Use tools to answer accurately.'; + + Provider := TLLMFactory.CreateProvider(Config); + + Runner := TAgentRunner.Create(Provider, Config, TConsoleObserver.Create); + try + Runner.RegisterProvider(TMyToolProvider.Create); // any TMCPToolProvider + Result := Runner.Run('How many .pas files are in Sources/AI/Graph?'); + Writeln(Result.FinalAnswer); + finally + Runner.Free; + end; +end; +``` + +`TAgentConfig` has factory constructors for the three built-in providers: + +```pascal +TAgentConfig.OpenAI('gpt-4o'); +TAgentConfig.Anthropic('claude-sonnet-4-6'); +TAgentConfig.Ollama('llama3.2'); // BaseUrl defaults to http://localhost:11434 +``` + +`TLLMFactory.CreateProvider(Config)` reads `Config.ProviderString` (`'openai:gpt-4o'`, `'anthropic:...'`, `'ollama:...'`) and returns the matching `ILLMProvider`. `ILLMProvider` is a single, small strategy interface — implement it yourself for any provider not built in: + +```pascal +ILLMProvider = interface + function Complete(const AMessages: TArray; + const ATools: TArray): TLLMResponse; + function ProviderName: string; + function ModelName: string; +end; +``` + +`IAgentObserver` gets called at every step of the ReAct loop (`OnIterationStart`, `OnToolCall`, `OnToolResult`, `OnLLMResponse`, `OnFinished`) — implement your own to stream progress into a UI, log file, or SSE endpoint instead of `TConsoleObserver`'s stdout. + +**Full working example:** [Examples/AI/AgentDemo](../../Examples/AI/AgentDemo/) + +--- + +## 🕸️ Dext.AI.Graph — Quick Start + +The graph API mirrors LangGraph's `StateGraph` almost one to one: + +```pascal +LangGraph (Python) Dext.AI.Graph (Delphi) +────────────────────────── ──────────────────────────────── +StateGraph(State) → TAgentGraph.Create +graph.add_node(name, fn) → Graph.AddNode(name, Handler) +graph.set_entry_point(name) → Graph.SetEntryPoint(name) +graph.add_edge(a, b) → Graph.AddEdge(a, b) +graph.add_conditional_edges → Graph.AddConditionalEdge(from, cond, routes) +START / END → GRAPH_START / GRAPH_END +graph.compile() → Graph.Compile(Provider, Config, Observer, Checkpointer) +compiled.invoke(input) → Agent.Run(input, threadId) +MemorySaver → TMemoryCheckpointer +interrupt_before → InterruptBefore([...]) / .RequireApproval(node) +Command(resume=...) → Agent.Resume(threadId) +Subgraphs → ICompiledAgent.AsNode +``` + +A minimal ReAct graph — `call_llm` calls the model, routes to `execute_tools` if it asked for a tool, loops back, and stops when the model has a final answer: + +```pascal +uses + Dext.AI.Graph.Contracts, Dext.AI.Graph.State, Dext.AI.Graph.Edge, + Dext.AI.Graph.Graph, Dext.AI.Graph.Compiled, Dext.AI.Graph.Checkpointer, + Dext.AI.Graph.Node.LLM, Dext.AI.Graph.Node.Tools; + +var + ToolsNode: TToolsNode; + LLMNode: TLLMNode; + Graph: TAgentGraph; + Agent: ICompiledAgent; + Result: TGraphRunResult; +begin + ToolsNode := TToolsNode.Create; + ToolsNode.RegisterProvider(TMyToolProvider.Create); + LLMNode := TLLMNode.Create(ToolsNode.GetToolSchemas); + + Graph := TAgentGraph.Create; + try + Agent := Graph + .AddNode('call_llm', LLMNode.AsHandler) + .AddNode('execute_tools', ToolsNode.AsHandler) + .SetEntryPoint('call_llm') + .AddConditionalEdge('call_llm', DefaultShouldContinue, + [TEdgeRoute.To_('execute_tools'), TEdgeRoute.ToEnd]) + .AddEdge('execute_tools', 'call_llm') + .Compile(Provider, Config, Observer, TMemoryCheckpointer.Create); + finally + Graph.Free; // TCompiledAgent copies nodes/edges — safe to free right after Compile + end; + + Result := Agent.Run('How many .pas files are in Sources/AI/Graph?', 'thread-1'); + Writeln(Result.FinalAnswer); +end; +``` + +**Full working example (all features below wired together):** [Examples/AI/GraphDemo](../../Examples/AI/GraphDemo/) + +### Core concepts + +| Type | Role | +|---|---| +| `TAgentState` | Immutable state that flows through the graph — messages, pending tool calls, current node, iteration count, metadata. Every `With*` method returns a **new** instance; nothing mutates in place. | +| `TNodeHandler` | `reference to function(const AState: TAgentState; const ACtx: TNodeContext): TAgentState` — a node is just a function from state to state. | +| `TEdge` | Fixed (`AddEdge`) or conditional (`AddConditionalEdge`, driven by a `TEdgeCondition` that inspects state and returns the next node's name). | +| `ICompiledAgent` | The result of `Compile()` — `Run`, `Resume`, `Cancel`, `GetState`, `AsNode`. | +| `ICheckpointer` | Persists a thread's `TAgentState` (as JSON) so the same `AThreadId` can be resumed across `Run` calls — and, with `TFileCheckpointer`, across process restarts. | + +### Human-in-the-loop + +Mark a node as requiring approval — the graph pauses **before** running it and returns `grsWaitingApproval` instead of executing: + +```pascal +Graph.RequireApproval('execute_tools'); +// equivalent: Graph.InterruptBefore(['execute_tools']); +``` + +```pascal +Result := Agent.Run(Input, ThreadId); +if Result.Status = grsWaitingApproval then +begin + Writeln('Pending node: ' + Result.PendingNode); + if UserApproves then + Result := Agent.Resume(ThreadId) + else + Agent.Cancel(ThreadId); +end; +``` + +`Resume` re-executes the paused node itself, then continues the loop normally. `Cancel` deletes the thread's checkpoint. + +### Checkpointing + +```pascal +TMemoryCheckpointer.Create; // process-local, gone on exit +TFileCheckpointer.Create; // JSON files under %TEMP%\dext-ai-graph +TFileCheckpointer.Create('C:\MyPath'); // or a path you choose +``` + +Both implement the tiny `ICheckpointer` interface (`Save`/`Load`/`Exists`/`Delete`) — implement your own to persist to a database table instead. + +### Subgraphs (`AsNode`) + +A compiled graph can be embedded as a single node of another graph — this is how you compose independent, separately-tested sub-agents (a "Fiscal" agent inside a larger "ERP" graph, for example) instead of flattening every sub-agent's nodes into one giant graph: + +```pascal +FiscalAgent := FiscalGraph.AddNode(...).SetEntryPoint(...).Compile(Provider, Config); + +ERPGraph.AddNode('fiscal_agent', FiscalAgent.AsNode); +``` + +`TAgentState` is a single concrete type across every graph in Dext.AI.Graph (unlike LangGraph's per-graph typed schemas), so there is no state-translation step at the subgraph boundary — the parent's state is passed straight through, the subgraph runs from its own entry point to its own `GRAPH_END`, and the resulting state (including every message it appended) flows back to the parent, which then decides what happens next via its own edges. + +> **Constraint:** a subgraph cannot itself declare `RequireApproval`/`InterruptBefore` on any node — `AsNode` raises `EGraphCompileError` immediately rather than silently skipping the approval step. If part of a subgraph's flow needs human approval, put `RequireApproval` on the **parent** node that wraps the subgraph call. Nested human-in-the-loop isn't supported yet. + +--- + +## 🧭 Coming from LangGraph? What's covered, what isn't + +| LangGraph | Dext.AI.Graph | +|---|---| +| `StateGraph` | ✅ `TAgentGraph` | +| node functions | ✅ `TNodeHandler` | +| `add_edge` / `add_conditional_edges` | ✅ `AddEdge` / `AddConditionalEdge` | +| `compile()` | ✅ `Compile()` | +| `MemorySaver` | ✅ `TMemoryCheckpointer` / `TFileCheckpointer` | +| `thread_id` multi-turn | ✅ `AThreadId` | +| `interrupt_before` + resume/cancel | ✅ `RequireApproval` / `InterruptBefore` + `Resume` / `Cancel` | +| Subgraphs | ✅ `ICompiledAgent.AsNode` | +| `recursion_limit` | ✅ `MaxIterations` | +| Typed per-graph state schema | ❌ `TAgentState` is one fixed type; extra data goes in `Metadata` (string→string) | +| Conditional/dynamic entry point | ❌ `GRAPH_START` exists only as a reserved name; entry is always fixed via `SetEntryPoint` | +| `interrupt_after` | ❌ only "before" is supported | +| Dynamic interrupts (`interrupt()` inside a node) | ❌ interrupts must be statically declared on the graph | +| `update_state` | ❌ `GetState` is read-only; no way to edit paused state before `Resume` | +| Nested human-in-the-loop | ❌ explicitly rejected by `AsNode` (fails loud, not silently) | +| `get_state_history` / time travel | ❌ the checkpointer keeps only the latest state per thread | +| `Store` (cross-thread long-term memory) | ❌ persistence is per-thread only | +| `stream_mode` (first-class streaming) | ⚠️ partial — `IAgentObserver` gives synchronous callbacks, not a stream/generator | +| Fan-out / parallel branches (`Send`) | ⚠️ **pitfall**: adding more than one `AddEdge` from the same source node is not an error — only the *first* one is ever used, the rest are silently ignored. There is no automatic parallel fan-out. | +| Retry policy / node result caching | ❌ not implemented | +| `Command` (node returns routing + state update together) | ❌ routing always goes through edges | + +If your use case needs real parallel branches, state history for auditing, or cross-thread memory, those are gaps today, not just missing docs. + +--- + +## 📂 Example Projects + +- **[AgentDemo](../../Examples/AI/AgentDemo/)** — single-agent ReAct loop with filesystem tools. +- **[GraphDemo](../../Examples/AI/GraphDemo/)** — the full graph feature set wired together: conditional routing, `RequireApproval` + `Resume`/`Cancel`, `TFileCheckpointer` persistence across restarts, and a `polish_agent` subgraph embedded via `AsNode`. diff --git a/Docs/Book/README.md b/Docs/Book/README.md index d80ac960..0a74fb37 100644 --- a/Docs/Book/README.md +++ b/Docs/Book/README.md @@ -127,6 +127,11 @@ - [AI Skills](13-ai-assistants/README.md) - Native AI integration skills - [MCP Server](15-mcp-server/README.md) - Model Context Protocol implementation +#### [16. AI Agents & Orchestration](16-ai-agents/README.md) ⭐ NEW + +- Dext.AI.Agent - Single-agent ReAct loop (LangChain-style) +- Dext.AI.Graph - Graph orchestration, checkpointing, human-in-the-loop, subgraphs (LangGraph-style) + --- ### Appendix @@ -154,6 +159,8 @@ Each chapter references working examples from the `Examples/` directory: | [Orm.EntityDemo](../../Examples/Orm.EntityDemo/) | ORM Basics | | [Hubs](../../Examples/Hubs/) | Real-Time SignalR | | [Desktop.MVVM.CustomerCRUD](../../Examples/Desktop.MVVM.CustomerCRUD/) | Navigator, MVVM, Testing | +| [AI.AgentDemo](../../Examples/AI/AgentDemo/) | Dext.AI.Agent, ReAct loop | +| [AI.GraphDemo](../../Examples/AI/GraphDemo/) | Dext.AI.Graph, checkpointing, HITL, subgraphs | --- diff --git a/Docs/roadmap/ai-roadmap.md b/Docs/roadmap/ai-roadmap.md index 8c2a0dc8..1d31e168 100644 --- a/Docs/roadmap/ai-roadmap.md +++ b/Docs/roadmap/ai-roadmap.md @@ -20,17 +20,31 @@ Permite expor ferramentas e recursos do Dext para agentes de IA externos (como C Inspirado no Microsoft Semantic Kernel, este módulo será o "cérebro" para integrar LLMs com código nativo. +> **Nota (2026-08):** o núcleo de orquestração (chamada de LLM multi-provider, function +> calling via RTTI, loop de agente com tools, e agora orquestração via grafo estilo +> LangGraph) já foi implementado — mas sob os nomes `Dext.AI.Agent` e `Dext.AI.Graph`, +> não `Dext.SemanticKernel`. Ver [Capítulo 16 do Book](../Book/16-ai-agents/README.md). +> Os itens abaixo marcados como concluídos refletem o que já existe sob esses nomes; +> o restante (embeddings, planner dedicado, plugins ao estilo Semantic Kernel) continua +> em aberto. Mantendo o roadmap original sem reescrever a decisão de nomenclatura — +> só sinalizando onde o que já existe se encaixa. + ### 1. Core Abstractions -- [ ] **IChatCompletion**: Interface unificada para chat (OpenAI, Azure OpenAI, Anthropic, Ollama). +- [x] **IChatCompletion**: interface unificada de chat multi-provider — implementada como + `ILLMProvider` (`Dext.AI.Agent.Contracts`), com providers para OpenAI, Anthropic e Ollama. - [ ] **ITextEmbedding**: Interface para geração de vetores (embeddings). - [ ] **Prompt Templates**: Engine para renderizar prompts dinâmicos com variáveis (`"Olá {{name}}, ajude-me com..."`). ### 2. Plugins & Native Functions (The "Glue") A capacidade de LLMs chamarem código Delphi (Function Calling). -- [ ] **Native Plugins**: Expor classes Delphi como "Skills" para a IA usando RTTI. - - Atributos: `[SKFunction]`, `[SKDescription]`. - - Geração automática de Schema JSON para a LLM entender a função. -- [ ] **Planner**: Um agente que decide quais funções chamar para resolver uma solicitação complexa do usuário. +- [x] **Native Plugins**: expor classes Delphi como tools para a IA via RTTI — implementado + como `TMCPToolProvider` + `[MCPTool]`/`[MCPParam]` (`Dext.AI.MCP.*`), reaproveitado + diretamente por `Dext.AI.Agent`/`Dext.AI.Graph` como o conjunto de tools do agente. + - Geração automática de Schema JSON para a LLM entender a função. ✅ +- [x] **Planner**: um agente que decide quais funções chamar para resolver uma solicitação + complexa — implementado como o loop ReAct de `TAgentRunner` (agente único) e como o + grafo `TAgentGraph`/`ICompiledAgent` (roteamento condicional, checkpoint, + human-in-the-loop, subgrafos via `AsNode`) para orquestração multi-etapa/multi-agente. ### 3. Structured Output (Pydantic-like) - [ ] **Schema Validation**: Garantir que a IA retorne JSON válido que mapeia exatamente para um `record` ou `class` Delphi. diff --git a/Examples/AI/AgentDemo/AgentDemo.dpr b/Examples/AI/AgentDemo/AgentDemo.dpr new file mode 100644 index 00000000..a8657494 --- /dev/null +++ b/Examples/AI/AgentDemo/AgentDemo.dpr @@ -0,0 +1,228 @@ +program AgentDemo; + +{$APPTYPE CONSOLE} + +uses + Winapi.Windows, + System.SysUtils, + System.Math, + System.JSON, + System.IOUtils, + System.Classes, + Dext.AI.MCP.Tools, + Dext.AI.MCP.Types, + Dext.AI.MCP.Protocol, + Dext.AI.MCP.Attributes, + Dext.AI.Agent.Contracts, + Dext.AI.Agent.Factory, + Dext.AI.Agent.Runner, + Dext.AI.Agent.Observer; + +// ─── Tools de demonstração ──────────────────────────────────────────────── +// Reutiliza EXATAMENTE o padrão TMCPToolProvider do Dext existente + +type + TFileSystemTools = class(TMCPToolProvider) + public + [MCPTool('list_files', 'Lista arquivos em um diretório')] + [MCPParam('path', 'Caminho do diretório', ptString, True)] + [MCPParam('extension', 'Filtro de extensão ex: .pas (opcional)', ptString, False)] + function ListFiles(const Args: TJSONObject): TMCPToolResult; + + [MCPTool('read_file', 'Lê o conteúdo de um arquivo texto')] + [MCPParam('path', 'Caminho completo do arquivo', ptString, True)] + [MCPParam('max_lines', 'Máximo de linhas (default: 50)', ptInteger, False)] + function ReadFile(const Args: TJSONObject): TMCPToolResult; + + [MCPTool('count_lines', 'Conta linhas de código em um arquivo')] + [MCPParam('path', 'Caminho completo do arquivo', ptString, True)] + function CountLines(const Args: TJSONObject): TMCPToolResult; + end; + +function TFileSystemTools.ListFiles(const Args: TJSONObject): TMCPToolResult; +var + Path, Ext, Pattern: string; + SR: TSearchRec; + JA: TJSONArray; +begin + Path := Args.GetValue('path', '.'); + Ext := Args.GetValue('extension', ''); + + if not DirectoryExists(Path) then + Exit(TMCPToolResult.Error('Diretório não encontrado: ' + Path)); + + Pattern := IncludeTrailingPathDelimiter(Path) + '*' + Ext; + JA := TJSONArray.Create; + try + if FindFirst(Pattern, faAnyFile, SR) = 0 then + try + repeat + if (SR.Attr and faDirectory) = 0 then + begin + var JO := TJSONObject.Create; + JO.AddPair('name', SR.Name); + JO.AddPair('size', TJSONNumber.Create(SR.Size)); + JA.AddElement(JO); + end; + until FindNext(SR) <> 0; + finally + FindClose(SR); + end; + Result := TMCPToolResult.Text( + Format('{"path":"%s","filter":"%s","count":%d,"files":%s}', + [Path, Ext, JA.Count, JA.ToJSON]) + ); + finally + JA.Free; + end; +end; + +function TFileSystemTools.ReadFile(const Args: TJSONObject): TMCPToolResult; +var + Path: string; + MaxLines, I: Integer; + Lines: TStringList; + SB: TStringBuilder; +begin + Path := Args.GetValue('path', ''); + MaxLines := Args.GetValue('max_lines', 50); + + if not FileExists(Path) then + Exit(TMCPToolResult.Error('Arquivo não encontrado: ' + Path)); + + Lines := TStringList.Create; + SB := TStringBuilder.Create; + try + Lines.LoadFromFile(Path, TEncoding.UTF8); + SB.AppendLine(Format('// %s — %d linhas total', [ExtractFileName(Path), Lines.Count])); + for I := 0 to Min(MaxLines - 1, Lines.Count - 1) do + SB.AppendFormat('%4d %s', [I + 1, Lines[I]]).AppendLine; + if Lines.Count > MaxLines then + SB.AppendLine(Format('// ... (%d linhas restantes)', [Lines.Count - MaxLines])); + Result := TMCPToolResult.Text(SB.ToString); + finally + Lines.Free; + SB.Free; + end; +end; + +function TFileSystemTools.CountLines(const Args: TJSONObject): TMCPToolResult; +var + Path: string; + Lines: TStringList; + Code, Comment, Blank: Integer; + Line: string; +begin + Path := Args.GetValue('path', ''); + if not FileExists(Path) then + Exit(TMCPToolResult.Error('Arquivo não encontrado: ' + Path)); + + Lines := TStringList.Create; + try + Lines.LoadFromFile(Path, TEncoding.UTF8); + Code := 0; Comment := 0; Blank := 0; + for Line in Lines do + begin + var T := Line.Trim; + if T = '' then Inc(Blank) + else if T.StartsWith('//') or T.StartsWith('{') or T.StartsWith('(*') then Inc(Comment) + else Inc(Code); + end; + Result := TMCPToolResult.Text( + Format('{"file":"%s","total":%d,"code":%d,"comments":%d,"blank":%d}', + [ExtractFileName(Path), Lines.Count, Code, Comment, Blank]) + ); + finally + Lines.Free; + end; +end; + +// ─── MAIN ───────────────────────────────────────────────────────────────── + +var + Config: TAgentConfig; + Provider: ILLMProvider; + Observer: IAgentObserver; + Runner: TAgentRunner; + Input: string; + Res: TAgentResult; + +begin + ReportMemoryLeaksOnShutdown := True; + + SetConsoleOutputCP(CP_UTF8); + SetConsoleCP(CP_UTF8); + + // ── Configuração via variável de ambiente ────────────────────────────── + // Para usar OpenAI: set OPENAI_API_KEY=sk-... + // Para usar Anthropic: set ANTHROPIC_API_KEY=sk-ant-... + // Para usar Ollama: (sem key — roda local) + + Config := TAgentConfig.OpenAI('gpt-4o'); // trocar aqui para mudar provider + Config.ApiKey := GetEnvironmentVariable('OPENAI_API_KEY'); + + // Descomente para usar Anthropic: + // Config := TAgentConfig.Anthropic('claude-sonnet-4-6'); + // Config.ApiKey := GetEnvironmentVariable('ANTHROPIC_API_KEY'); + + // Descomente para usar Ollama local (sem key): + // Config := TAgentConfig.Ollama('llama3.2'); + + Config.SystemPrompt := + 'Você é um assistente técnico especializado em projetos Delphi. ' + + 'Analise os arquivos do projeto e responda com precisão. ' + + 'Nunca invente dados — use apenas as tools disponíveis.'; + + if (Config.ApiKey = '') and not Config.ProviderString.StartsWith('ollama') then + begin + Writeln('ERRO: API key não encontrada.'); + Writeln('Para OpenAI: set OPENAI_API_KEY=sk-...'); + Writeln('Para Anthropic: set ANTHROPIC_API_KEY=sk-ant-...'); + ExitCode := 1; + Exit; + end; + + // ── Criar provider via Factory (igual ao init_chat_model do LangChain) ─ + try + Provider := TLLMFactory.CreateProvider(Config); + except + on E: ELLMProviderError do + begin + Writeln('ERRO: ' + E.Message); + ExitCode := 1; + Exit; + end; + end; + + Observer := TConsoleObserver.Create; + Runner := TAgentRunner.Create(Provider, Config, Observer); + try + Runner.RegisterProvider(TFileSystemTools.Create); + + Writeln('═══════════════════════════════════════════════════════'); + Writeln(Format(' Dext.AI.Agent — Demo ao Vivo', [])); + Writeln(Format(' Provider: %s | Model: %s', [Provider.ProviderName, Provider.ModelName])); + Writeln(' Digite sua pergunta. Enter em branco para sair.'); + Writeln('═══════════════════════════════════════════════════════'); + Writeln; + + repeat + Write('Pergunta: '); + Readln(Input); + if Input.Trim = '' then Break; + + Writeln; + Res := Runner.Run(Input); + + if not Res.Success then + begin + Writeln; + Writeln('ERRO: ' + Res.ErrorMsg); + end; + Writeln; + until False; + + finally + Runner.Free; + end; +end. diff --git a/Examples/AI/AgentDemo/AgentDemo.dproj b/Examples/AI/AgentDemo/AgentDemo.dproj new file mode 100644 index 00000000..10cb18f9 --- /dev/null +++ b/Examples/AI/AgentDemo/AgentDemo.dproj @@ -0,0 +1,142 @@ + + + True + Console + Debug + None + AgentDemo.dpr + Win32 + {B168E87A-A25E-49A2-806C-D4F584E6FEAD} + AgentDemo + 20.4 + 3 + + + true + + + true + Base + true + + + true + Base + true + + + true + Base + true + + + true + Base + true + + + true + Cfg_2 + true + true + + + true + Cfg_2 + true + true + + + AgentDemo + ..\..\Output\$(ProductVersion)\$(Platform)\$(Config) + ..\..\Output\ + 00400000 + System;Xml;Data;Datasnap;Web;Soap;$(DCC_Namespace) + ..\..\..\Sources\AI\Agent;..\..\..\Sources\AI\Agent\Providers;..\..\..\Output\$(ProductVersion)\$(Platform)\$(Config);$(DCC_UnitSearchPath) + $(BDS)\bin\delphi_PROJECTICNS.icns + $(BDS)\bin\delphi_PROJECTICON.ico + + CompanyName=;FileDescription=;FileVersion=1.0.0.0;InternalName=;LegalCopyright=;LegalTrademarks=;OriginalFilename=;ProductName=;ProductVersion=1.0.0.0;Comments=;CFBundleName= + 1046 + + + Debug + Winapi;System.Win;Data.Win;Datasnap.Win;Web.Win;Soap.Win;Xml.Win;Bde;$(DCC_Namespace) + CompanyName=;FileDescription=$(MSBuildProjectName);FileVersion=1.0.0.0;InternalName=;LegalCopyright=;LegalTrademarks=;OriginalFilename=;ProgramID=com.embarcadero.$(MSBuildProjectName);ProductName=$(MSBuildProjectName);ProductVersion=1.0.0.0;Comments= + 1033 + + + Debug + Winapi;System.Win;Data.Win;Datasnap.Win;Web.Win;Soap.Win;Xml.Win;$(DCC_Namespace) + CompanyName=;FileDescription=$(MSBuildProjectName);FileVersion=1.0.0.0;InternalName=;LegalCopyright=;LegalTrademarks=;OriginalFilename=;ProgramID=com.embarcadero.$(MSBuildProjectName);ProductName=$(MSBuildProjectName);ProductVersion=1.0.0.0;Comments= + 1033 + + + 0 + RELEASE;$(DCC_Define) + false + 0 + + + DEBUG;$(DCC_Define) + true + true + false + true + + + none + true + true + true + + + none + + + + MainSource + + + Base + + + Cfg_1 + Base + + + Cfg_2 + Base + + + + Delphi.Personality.12 + + + + + AgentDemo.dpr + + + + + False + False + False + False + False + True + True + False + False + False + False + + + 12 + + + + + diff --git a/Examples/AI/AgentDemo/AgentDemo.res b/Examples/AI/AgentDemo/AgentDemo.res new file mode 100644 index 00000000..e30ea808 Binary files /dev/null and b/Examples/AI/AgentDemo/AgentDemo.res differ diff --git a/Examples/AI/AgentDemo/appsettings.json b/Examples/AI/AgentDemo/appsettings.json new file mode 100644 index 00000000..23d3072e --- /dev/null +++ b/Examples/AI/AgentDemo/appsettings.json @@ -0,0 +1,12 @@ +{ + "Provider": "openai:gpt-4o", + "OpenAI": { + "ApiKeyEnv": "OPENAI_API_KEY" + }, + "Anthropic": { + "ApiKeyEnv": "ANTHROPIC_API_KEY" + }, + "Ollama": { + "BaseUrl": "http://localhost:11434" + } +} diff --git a/Examples/AI/GraphDemo/GraphDemo.dpr b/Examples/AI/GraphDemo/GraphDemo.dpr new file mode 100644 index 00000000..9781b39c --- /dev/null +++ b/Examples/AI/GraphDemo/GraphDemo.dpr @@ -0,0 +1,336 @@ +program GraphDemo; + +{$APPTYPE CONSOLE} + +uses + Winapi.Windows, + System.SysUtils, + System.Math, + System.JSON, + System.IOUtils, + System.Classes, + Dext.AI.MCP.Tools, + Dext.AI.MCP.Types, + Dext.AI.MCP.Protocol, + Dext.AI.MCP.Attributes, + Dext.AI.Agent.Contracts, + Dext.AI.Agent.Factory, + Dext.AI.Agent.Observer, + Dext.AI.Graph.Contracts, + Dext.AI.Graph.State, + Dext.AI.Graph.Edge, + Dext.AI.Graph.Graph, + Dext.AI.Graph.Compiled, + Dext.AI.Graph.Checkpointer, + Dext.AI.Graph.Node.LLM, + Dext.AI.Graph.Node.Tools; + +type + TFileSystemTools = class(TMCPToolProvider) + public + [MCPTool('list_files', 'Lista arquivos em um diretório')] + [MCPParam('path', 'Caminho do diretório', ptString, True)] + [MCPParam('extension', 'Filtro ex: .pas (opcional)', ptString, False)] + function ListFiles(const Args: TJSONObject): TMCPToolResult; + + [MCPTool('read_file', 'Lê o conteúdo de um arquivo')] + [MCPParam('path', 'Caminho completo do arquivo', ptString, True)] + [MCPParam('max_lines', 'Máximo de linhas (default: 50)', ptInteger, False)] + function ReadFile(const Args: TJSONObject): TMCPToolResult; + + [MCPTool('count_lines', 'Conta linhas de código em um arquivo')] + [MCPParam('path', 'Caminho completo do arquivo', ptString, True)] + function CountLines(const Args: TJSONObject): TMCPToolResult; + end; + +function TFileSystemTools.ListFiles(const Args: TJSONObject): TMCPToolResult; +var + Path, Ext, Pattern: string; + SR: TSearchRec; + JA: TJSONArray; +begin + Path := Args.GetValue('path', '.'); + Ext := Args.GetValue('extension', ''); + + if not DirectoryExists(Path) then + Exit(TMCPToolResult.Error('Diretório não encontrado: ' + Path)); + + Pattern := IncludeTrailingPathDelimiter(Path) + '*' + Ext; + JA := TJSONArray.Create; + try + if FindFirst(Pattern, faAnyFile, SR) = 0 then + try + repeat + if (SR.Attr and faDirectory) = 0 then + begin + var JO := TJSONObject.Create; + JO.AddPair('name', SR.Name); + JO.AddPair('size', TJSONNumber.Create(SR.Size)); + JA.AddElement(JO); + end; + until FindNext(SR) <> 0; + finally + FindClose(SR); + end; + Result := TMCPToolResult.Text( + Format('{"path":"%s","filter":"%s","count":%d,"files":%s}', + [Path, Ext, JA.Count, JA.ToJSON]) + ); + finally + JA.Free; + end; +end; + +function TFileSystemTools.ReadFile(const Args: TJSONObject): TMCPToolResult; +var + Path: string; + MaxLines, I: Integer; + Lines: TStringList; + SB: TStringBuilder; +begin + Path := Args.GetValue('path', ''); + MaxLines := Args.GetValue('max_lines', 50); + + if not FileExists(Path) then + Exit(TMCPToolResult.Error('Arquivo não encontrado: ' + Path)); + + Lines := TStringList.Create; + SB := TStringBuilder.Create; + try + Lines.LoadFromFile(Path, TEncoding.UTF8); + SB.AppendLine(Format('// %s — %d linhas total', [ExtractFileName(Path), Lines.Count])); + for I := 0 to Min(MaxLines - 1, Lines.Count - 1) do + SB.AppendFormat('%4d %s', [I + 1, Lines[I]]).AppendLine; + if Lines.Count > MaxLines then + SB.AppendLine(Format('// ... (%d linhas restantes)', [Lines.Count - MaxLines])); + Result := TMCPToolResult.Text(SB.ToString); + finally + Lines.Free; + SB.Free; + end; +end; + +function TFileSystemTools.CountLines(const Args: TJSONObject): TMCPToolResult; +var + Path: string; + Lines: TStringList; + Code, Comment, Blank: Integer; + Line: string; +begin + Path := Args.GetValue('path', ''); + if not FileExists(Path) then + Exit(TMCPToolResult.Error('Arquivo não encontrado: ' + Path)); + + Lines := TStringList.Create; + try + Lines.LoadFromFile(Path, TEncoding.UTF8); + Code := 0; Comment := 0; Blank := 0; + for Line in Lines do + begin + var T := Line.Trim; + if T = '' then Inc(Blank) + else if T.StartsWith('//') or T.StartsWith('{') or T.StartsWith('(*') then Inc(Comment) + else Inc(Code); + end; + Result := TMCPToolResult.Text( + Format('{"file":"%s","total":%d,"code":%d,"comments":%d,"blank":%d}', + [ExtractFileName(Path), Lines.Count, Code, Comment, Blank]) + ); + finally + Lines.Free; + end; +end; + +// Condição de roteamento do nó 'call_llm'. Igual ao DefaultShouldContinue, +// mas manda o fluxo passar pelo subgrafo 'polish_agent' antes do GRAPH_END, +// em vez de encerrar direto — é assim que se pluga um subgrafo no meio do +// roteamento condicional de um grafo existente. +function ShouldContinueOrPolish(const AState: TAgentState): string; +begin + if AState.HasPendingCalls then + Result := 'execute_tools' + else + Result := 'polish_agent'; +end; + +var + Config: TAgentConfig; + Provider: ILLMProvider; + Observer: IAgentObserver; + ToolsNode: TToolsNode; + LLMNode: TLLMNode; + Graph: TAgentGraph; + Agent: ICompiledAgent; + Checkpointer: ICheckpointer; + Input, ThreadId, CheckpointDir: string; + RunResult: TGraphRunResult; + Confirm: string; + + // ── Subgrafo "polish_agent" ──────────────────────────────────────────── + // Um grafo compilado independente (seu próprio TAgentGraph, seu próprio + // TLLMNode, sem tools), embutido no grafo principal como um nó comum via + // ICompiledAgent.AsNode. Reescreve a resposta bruta do 'call_llm' de forma + // mais objetiva antes de virar a resposta final ao usuário. + // + // IMPORTANTE: um subgrafo não pode ter RequireApproval/InterruptBefore em + // nenhum nó seu — AsNode levantaria EGraphCompileError (aprovação humana + // aninhada não é suportada). Se o fluxo do subgrafo precisar de aprovação, + // ela deve ficar no nó do grafo PAI que o invoca (aqui, seria em + // 'polish_agent' do grafo principal, não dentro do PolishGraph). + PolishConfig: TAgentConfig; + PolishLLMNode: TLLMNode; + PolishGraph: TAgentGraph; + PolishAgent: ICompiledAgent; + +begin + ReportMemoryLeaksOnShutdown := True; + + SetConsoleOutputCP(CP_UTF8); + SetConsoleCP(CP_UTF8); + + Config := TAgentConfig.OpenAI('gpt-4o'); + Config.ApiKey := GetEnvironmentVariable('OPENAI_API_KEY'); + Config.SystemPrompt := + 'Você é um assistente técnico especializado em projetos Delphi. ' + + 'Use as tools disponíveis para responder. Nunca invente dados.'; + + if Config.ApiKey = '' then + begin + Writeln('ERRO: Defina OPENAI_API_KEY'); + ExitCode := 1; + Exit; + end; + + try + Provider := TLLMFactory.CreateProvider(Config); + except + on E: ELLMProviderError do + begin + Writeln('ERRO: ' + E.Message); + ExitCode := 1; + Exit; + end; + end; + + Observer := TConsoleObserver.Create; + ToolsNode := TToolsNode.Create; + LLMNode := nil; + PolishLLMNode := nil; + try + ToolsNode.RegisterProvider(TFileSystemTools.Create); + LLMNode := TLLMNode.Create(ToolsNode.GetToolSchemas); + + // TFileCheckpointer em vez de TMemoryCheckpointer: o estado da thread + // sobrevive ao encerramento do processo — feche o GraphDemo, abra de + // novo, use a mesma ThreadId e o histórico continua de onde parou. + Checkpointer := TFileCheckpointer.Create; + CheckpointDir := TPath.Combine(TPath.GetTempPath, 'dext-ai-graph'); + + // ── Compila o subgrafo 'polish_agent' primeiro (grafo independente) ── + PolishConfig := Config; + PolishConfig.SystemPrompt := + 'Reescreva a última resposta do assistente de forma mais clara e ' + + 'objetiva para o usuário final. Mantenha os fatos exatamente como ' + + 'estão — não invente, não adicione e não remova informação.'; + PolishLLMNode := TLLMNode.Create(nil); // sem tools: só reescreve texto + + PolishGraph := TAgentGraph.Create; + try + PolishAgent := PolishGraph + .AddNode('polish_llm', PolishLLMNode.AsHandler) + .SetEntryPoint('polish_llm') + .Compile(Provider, PolishConfig, Observer, nil); + finally + PolishGraph.Free; + end; + + // ── Grafo principal: embute o subgrafo como o nó 'polish_agent' ────── + Graph := TAgentGraph.Create; + try + Agent := Graph + .AddNode('call_llm', LLMNode.AsHandler) + .AddNode('execute_tools', ToolsNode.AsHandler) + .AddNode('polish_agent', PolishAgent.AsNode) + .SetEntryPoint('call_llm') + .AddConditionalEdge('call_llm', + ShouldContinueOrPolish, + [TEdgeRoute.To_('execute_tools'), TEdgeRoute.To_('polish_agent')]) + .AddEdge('execute_tools', 'call_llm') + .AddEdge('polish_agent', GRAPH_END) + .RequireApproval('execute_tools') + .Compile(Provider, Config, Observer, Checkpointer); + finally + Graph.Free; + end; + + Writeln('═══════════════════════════════════════════════════════'); + Writeln(' Dext.AI.Graph — Demo (estilo LangGraph)'); + Writeln(Format(' Provider: %s | Model: %s', + [Provider.ProviderName, Provider.ModelName])); + Writeln(' Grafo: call_llm -> execute_tools (aprovação) -> polish_agent (subgrafo) -> fim'); + Writeln(' Checkpoint em disco: ' + CheckpointDir); + Writeln(' Digite sua pergunta, ":estado" para inspecionar a thread, ou Enter em branco para sair.'); + Writeln('═══════════════════════════════════════════════════════'); + Writeln; + + ThreadId := 'demo-session-001'; + + repeat + Write('Pergunta: '); + Readln(Input); + if Input.Trim = '' then + Break; + + if SameText(Input.Trim, ':estado') then + begin + var CurState := TAgentState(Agent.GetState(ThreadId)); + if CurState = nil then + Writeln(' (nenhum estado salvo ainda para esta thread)') + else + Writeln(Format( + ' nó atual=%s | iteração=%d | concluído=%s | mensagens=%d | pendências=%d', + [CurState.CurrentNode, CurState.Iteration, BoolToStr(CurState.IsDone, True), + Length(CurState.Messages), Length(CurState.PendingCalls)])); + Writeln; + Continue; + end; + + Writeln; + RunResult := Agent.Run(Input, ThreadId); + + case RunResult.Status of + grsFinished: + Writeln(''); + + grsWaitingApproval: + begin + Writeln; + Writeln('⏸ Aguardando aprovação humana...'); + Writeln(' Nó pendente: ' + RunResult.PendingNode); + Write(' Aprovar? (s/n): '); + Readln(Confirm); + if Confirm.ToLower = 's' then + begin + RunResult := Agent.Resume(ThreadId); + Writeln(' Continuando...'); + end + else + begin + Agent.Cancel(ThreadId); + Writeln(' Cancelado.'); + end; + end; + + grsError: + Writeln('ERRO: ' + RunResult.ErrorMsg); + end; + Writeln; + until False; + finally + Agent := nil; + PolishAgent := nil; + LLMNode.Free; + PolishLLMNode.Free; + ToolsNode.Free; + end; +end. diff --git a/Examples/AI/GraphDemo/GraphDemo.dproj b/Examples/AI/GraphDemo/GraphDemo.dproj new file mode 100644 index 00000000..ccbfe80e --- /dev/null +++ b/Examples/AI/GraphDemo/GraphDemo.dproj @@ -0,0 +1,140 @@ + + + True + Console + Debug + None + GraphDemo.dpr + Win32 + {A7C3E91F-4B2D-48E1-9C6A-1F8D3E5B7024} + GraphDemo + 20.4 + 3 + + + true + + + true + Base + true + + + true + Base + true + + + true + Base + true + + + true + Base + true + + + true + Cfg_2 + true + true + + + true + Cfg_2 + true + true + + + GraphDemo + ..\..\Output\$(ProductVersion)\$(Platform)\$(Config) + ..\..\Output\ + 00400000 + System;Xml;Data;Datasnap;Web;Soap;$(DCC_Namespace) + ..\..\..\Sources\AI\Agent;..\..\..\Sources\AI\Agent\Providers;..\..\..\Sources\AI\MCP;..\..\..\Sources\AI\Graph;..\..\..\Sources\AI\Graph\Nodes;..\..\..\Sources\Core;..\..\..\Sources\Core\Base;..\..\..\Sources\Core\Json;..\..\..\Sources\Common;..\..\..\Output\$(ProductVersion)\$(Platform)\$(Config);..\..\..\Output\$(ProductVersion)\$(Platform)\Release + $(BDS)\bin\delphi_PROJECTICNS.icns + $(BDS)\bin\delphi_PROJECTICON.ico + CompanyName=;FileDescription=;FileVersion=1.0.0.0;InternalName=;LegalCopyright=;LegalTrademarks=;OriginalFilename=;ProductName=;ProductVersion=1.0.0.0;Comments=;CFBundleName= + 1046 + + + Debug + Winapi;System.Win;Data.Win;Datasnap.Win;Web.Win;Soap.Win;Xml.Win;Bde;$(DCC_Namespace) + CompanyName=;FileDescription=$(MSBuildProjectName);FileVersion=1.0.0.0;InternalName=;LegalCopyright=;LegalTrademarks=;OriginalFilename=;ProgramID=com.embarcadero.$(MSBuildProjectName);ProductName=$(MSBuildProjectName);ProductVersion=1.0.0.0;Comments= + 1033 + + + Debug + Winapi;System.Win;Data.Win;Datasnap.Win;Web.Win;Soap.Win;Xml.Win;$(DCC_Namespace) + CompanyName=;FileDescription=$(MSBuildProjectName);FileVersion=1.0.0.0;InternalName=;LegalCopyright=;LegalTrademarks=;OriginalFilename=;ProgramID=com.embarcadero.$(MSBuildProjectName);ProductName=$(MSBuildProjectName);ProductVersion=1.0.0.0;Comments= + 1033 + + + 0 + RELEASE;$(DCC_Define) + false + 0 + + + DEBUG;$(DCC_Define) + true + true + false + true + + + none + true + true + true + + + none + + + + MainSource + + + Base + + + Cfg_1 + Base + + + Cfg_2 + Base + + + + Delphi.Personality.12 + + + + + GraphDemo.dpr + + + + + False + False + False + False + False + True + True + False + False + False + False + + + 12 + + + + + diff --git a/Examples/AI/GraphDemo/GraphDemo.res b/Examples/AI/GraphDemo/GraphDemo.res new file mode 100644 index 00000000..e30ea808 Binary files /dev/null and b/Examples/AI/GraphDemo/GraphDemo.res differ diff --git a/Sources/AI/Agent/Dext.AI.Agent.Contracts.pas b/Sources/AI/Agent/Dext.AI.Agent.Contracts.pas new file mode 100644 index 00000000..03cc4c80 --- /dev/null +++ b/Sources/AI/Agent/Dext.AI.Agent.Contracts.pas @@ -0,0 +1,171 @@ +{***************************************************************************} +{ } +{ Dext Framework } +{ } +{ Dext.AI.Agent - Multi-Provider LLM Agent } +{ } +{***************************************************************************} +{ } +{ Description: } +{ Core contracts for the Dext.AI.Agent framework: message/response } +{ types, the ILLMProvider strategy interface, agent configuration and } +{ the observer interface used to report ReAct loop progress. } +{ } +{***************************************************************************} +unit Dext.AI.Agent.Contracts; + +interface + +uses + System.SysUtils, System.JSON; + +type + TLLMRole = (lrSystem, lrUser, lrAssistant, lrToolResult); + TLLMStopReason = (srEndTurn, srToolUse, srMaxTokens, srError); + + TLLMToolCall = record + Id: string; + Name: string; + ArgsJson: string; + end; + + TLLMMessage = record + Role: TLLMRole; + Content: string; + ToolCallId: string; + ToolCalls: TArray; + public + class function User(const AContent: string): TLLMMessage; static; + class function System(const AContent: string): TLLMMessage; static; + class function Assistant(const AContent: string; + const AToolCalls: TArray = nil): TLLMMessage; static; + class function ToolResult(const AToolCallId, AContent: string): TLLMMessage; static; + end; + + TLLMResponse = record + Content: string; + StopReason: TLLMStopReason; + ToolCalls: TArray; + InputTokens: Integer; + OutputTokens: Integer; + end; + + TToolSchema = record + Name: string; + Description: string; + InputSchema: string; // JSON Schema serializado + end; + + // Interface única — o Strategy + ILLMProvider = interface + ['{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}'] + function Complete( + const AMessages: TArray; + const ATools: TArray + ): TLLMResponse; + function ProviderName: string; + function ModelName: string; + end; + + TAgentConfig = record + ProviderString: string; // ex: 'openai:gpt-4o' ou 'anthropic:claude-sonnet-4-6' + ApiKey: string; + BaseUrl: string; // para Ollama: 'http://localhost:11434' + MaxTokens: Integer; + MaxIterations: Integer; + SystemPrompt: string; + public + class function OpenAI(const AModel: string = 'gpt-4o'): TAgentConfig; static; + class function Anthropic(const AModel: string = 'claude-sonnet-4-6'): TAgentConfig; static; + class function Ollama(const AModel: string = 'llama3.2'): TAgentConfig; static; + end; + + TAgentResult = record + FinalAnswer: string; + Iterations: Integer; + Success: Boolean; + ErrorMsg: string; + end; + + IAgentObserver = interface + ['{B2C3D4E5-F6A7-8901-BCDE-F12345678901}'] + procedure OnIterationStart(AIteration: Integer); + procedure OnToolCall(const AToolName, AArgsJson: string); + procedure OnToolResult(const AToolName, AResult: string); + procedure OnLLMResponse(const AContent: string; AStopReason: TLLMStopReason); + procedure OnFinished(const AAnswer: string; AIterations: Integer); + end; + + ELLMProviderError = class(Exception); + +implementation + +const + DEFAULT_SYSTEM_PROMPT = + 'You are a helpful assistant. Use the available tools to answer accurately. ' + + 'Never invent data — only use what the tools return.'; + +{ TLLMMessage } + +class function TLLMMessage.User(const AContent: string): TLLMMessage; +begin + Result := Default(TLLMMessage); + Result.Role := lrUser; + Result.Content := AContent; +end; + +class function TLLMMessage.System(const AContent: string): TLLMMessage; +begin + Result := Default(TLLMMessage); + Result.Role := lrSystem; + Result.Content := AContent; +end; + +class function TLLMMessage.Assistant(const AContent: string; + const AToolCalls: TArray): TLLMMessage; +begin + Result := Default(TLLMMessage); + Result.Role := lrAssistant; + Result.Content := AContent; + Result.ToolCalls := AToolCalls; +end; + +class function TLLMMessage.ToolResult(const AToolCallId, AContent: string): TLLMMessage; +begin + Result := Default(TLLMMessage); + Result.Role := lrToolResult; + Result.Content := AContent; + Result.ToolCallId := AToolCallId; +end; + +{ TAgentConfig } + +class function TAgentConfig.OpenAI(const AModel: string): TAgentConfig; +begin + Result := Default(TAgentConfig); + Result.ProviderString := 'openai:' + AModel; + Result.MaxTokens := 4096; + Result.MaxIterations := 15; + Result.SystemPrompt := DEFAULT_SYSTEM_PROMPT; +end; + +class function TAgentConfig.Anthropic(const AModel: string): TAgentConfig; +begin + Result := Default(TAgentConfig); + Result.ProviderString := 'anthropic:' + AModel; + Result.MaxTokens := 4096; + Result.MaxIterations := 15; + Result.SystemPrompt := DEFAULT_SYSTEM_PROMPT; +end; + +class function TAgentConfig.Ollama(const AModel: string): TAgentConfig; +begin + Result := Default(TAgentConfig); + Result.ProviderString := 'ollama:' + AModel; + Result.BaseUrl := 'http://localhost:11434'; + Result.MaxTokens := 4096; + Result.MaxIterations := 15; + Result.SystemPrompt := DEFAULT_SYSTEM_PROMPT; +end; + +end. diff --git a/Sources/AI/Agent/Dext.AI.Agent.Factory.pas b/Sources/AI/Agent/Dext.AI.Agent.Factory.pas new file mode 100644 index 00000000..9aa9ea90 --- /dev/null +++ b/Sources/AI/Agent/Dext.AI.Agent.Factory.pas @@ -0,0 +1,98 @@ +{***************************************************************************} +{ } +{ Dext Framework } +{ } +{ Dext.AI.Agent - Multi-Provider LLM Agent } +{ } +{***************************************************************************} +{ } +{ Description: } +{ TLLMFactory resolves the correct ILLMProvider from a config string, } +{ the same idea as LangChain's init_chat_model('provider:model'). } +{ } +{***************************************************************************} +unit Dext.AI.Agent.Factory; + +interface + +uses + Dext.AI.Agent.Contracts; + +type + TLLMFactory = class + public + // Cria o provider correto baseado em ProviderString + // Exemplos: + // 'openai:gpt-4o' + // 'openai:gpt-4-turbo' + // 'anthropic:claude-sonnet-4-6' + // 'anthropic:claude-haiku-4-5' + // 'ollama:llama3.2' + // 'ollama:mistral' + class function CreateProvider(const AConfig: TAgentConfig): ILLMProvider; + + // Parse do ProviderString → (ProviderName, ModelName) + class procedure ParseProviderString( + const AProviderString: string; + out AProvider, AModel: string + ); + end; + +implementation + +uses + System.SysUtils, + Dext.AI.Agent.Provider.OpenAI, + Dext.AI.Agent.Provider.Anthropic, + Dext.AI.Agent.Provider.Ollama; + +class procedure TLLMFactory.ParseProviderString( + const AProviderString: string; + out AProvider, AModel: string +); +var + Parts: TArray; +begin + Parts := AProviderString.Split([':']); + if Length(Parts) >= 1 then + AProvider := Parts[0].ToLower + else + AProvider := ''; + + if Length(Parts) >= 2 then + AModel := Parts[1] + else + AModel := ''; +end; + +class function TLLMFactory.CreateProvider(const AConfig: TAgentConfig): ILLMProvider; +var + ProviderName, ModelName: string; +begin + ParseProviderString(AConfig.ProviderString, ProviderName, ModelName); + + if ProviderName = 'openai' then + begin + if ModelName = '' then ModelName := 'gpt-4o'; + Result := TOpenAIProvider.Create(AConfig.ApiKey, ModelName, AConfig.MaxTokens); + end + else if ProviderName = 'anthropic' then + begin + if ModelName = '' then ModelName := 'claude-sonnet-4-6'; + Result := TAnthropicProvider.Create(AConfig.ApiKey, ModelName, AConfig.MaxTokens); + end + else if ProviderName = 'ollama' then + begin + if ModelName = '' then ModelName := 'llama3.2'; + var BaseUrl := AConfig.BaseUrl; + if BaseUrl = '' then BaseUrl := 'http://localhost:11434'; + Result := TOllamaProvider.Create(BaseUrl, ModelName, AConfig.MaxTokens); + end + else + raise ELLMProviderError.CreateFmt( + 'Provider desconhecido: "%s". Use: openai, anthropic ou ollama.', + [ProviderName] + ); +end; + +end. diff --git a/Sources/AI/Agent/Dext.AI.Agent.Observer.pas b/Sources/AI/Agent/Dext.AI.Agent.Observer.pas new file mode 100644 index 00000000..f2acae8b --- /dev/null +++ b/Sources/AI/Agent/Dext.AI.Agent.Observer.pas @@ -0,0 +1,78 @@ +{***************************************************************************} +{ } +{ Dext Framework } +{ } +{ Dext.AI.Agent - Multi-Provider LLM Agent } +{ } +{***************************************************************************} +{ } +{ Description: } +{ Console implementation of IAgentObserver - prints ReAct loop progress } +{ (iterations, tool calls, tool results, LLM responses) to stdout. } +{ } +{***************************************************************************} +unit Dext.AI.Agent.Observer; + +interface + +uses + Dext.AI.Agent.Contracts; + +type + TConsoleObserver = class(TInterfacedObject, IAgentObserver) + public + procedure OnIterationStart(AIteration: Integer); + procedure OnToolCall(const AToolName, AArgsJson: string); + procedure OnToolResult(const AToolName, AResult: string); + procedure OnLLMResponse(const AContent: string; AStopReason: TLLMStopReason); + procedure OnFinished(const AAnswer: string; AIterations: Integer); + end; + +implementation + +uses System.SysUtils; + +procedure TConsoleObserver.OnIterationStart(AIteration: Integer); +begin + Writeln; + Writeln(Format('[ITERAÇÃO %d] ─────────────────────────────', [AIteration])); +end; + +procedure TConsoleObserver.OnToolCall(const AToolName, AArgsJson: string); +var + Preview: string; +begin + Preview := AArgsJson; + if Length(Preview) > 80 then Preview := Preview.Substring(0, 80) + '...'; + Writeln(Format(' → Tool: %s', [AToolName])); + Writeln(Format(' Args: %s', [Preview])); +end; + +procedure TConsoleObserver.OnToolResult(const AToolName, AResult: string); +var + Preview: string; +begin + Preview := AResult; + if Length(Preview) > 120 then Preview := Preview.Substring(0, 120) + '...'; + Writeln(Format(' ← Resultado: %s', [Preview])); +end; + +procedure TConsoleObserver.OnLLMResponse(const AContent: string; AStopReason: TLLMStopReason); +begin + case AStopReason of + srToolUse: Writeln(' ⚡ LLM solicitou tool call'); + srEndTurn: Writeln(' ✓ LLM finalizou resposta'); + srMaxTokens: Writeln(' ⚠ Limite de tokens atingido'); + srError: Writeln(' ✗ Erro no LLM'); + end; +end; + +procedure TConsoleObserver.OnFinished(const AAnswer: string; AIterations: Integer); +begin + Writeln; + Writeln(Format('── RESPOSTA FINAL (%d iterações) ──────────', [AIterations])); + Writeln(AAnswer); + Writeln('──────────────────────────────────────────'); +end; + +end. diff --git a/Sources/AI/Agent/Dext.AI.Agent.Runner.pas b/Sources/AI/Agent/Dext.AI.Agent.Runner.pas new file mode 100644 index 00000000..696556c6 --- /dev/null +++ b/Sources/AI/Agent/Dext.AI.Agent.Runner.pas @@ -0,0 +1,321 @@ +{***************************************************************************} +{ } +{ Dext Framework } +{ } +{ Dext.AI.Agent - Multi-Provider LLM Agent } +{ } +{***************************************************************************} +{ } +{ Description: } +{ The ReAct loop. Provider-agnostic - talks only to ILLMProvider and to } +{ TMCPToolProvider subclasses, both already part of the Dext framework. } +{ } +{***************************************************************************} +unit Dext.AI.Agent.Runner; + +interface + +uses + Dext.AI.Agent.Contracts, + Dext.AI.MCP.Tools, + Dext.AI.MCP.Types, + Dext.AI.MCP.Protocol, + Dext.AI.MCP.Attributes, + Dext.Core.Reflection, + System.Rtti, + System.SysUtils, + System.JSON, + System.Generics.Collections; + +type + TAgentRunner = class + private + FProvider: ILLMProvider; + FConfig: TAgentConfig; + FObserver: IAgentObserver; + FProviders: TObjectList; + + function BuildToolSchemas: TArray; + function BuildInputSchema(AMethod: TRttiMethod): string; + function ExecuteTool(const AToolName, AArgsJson: string): string; + function ToolResultToText(const AResult: TMCPToolResult): string; + public + constructor Create( + AProvider: ILLMProvider; + const AConfig: TAgentConfig; + AObserver: IAgentObserver = nil + ); + destructor Destroy; override; + procedure RegisterProvider(AProvider: TMCPToolProvider); + function Run(const AUserInput: string): TAgentResult; + end; + +implementation + +{ TAgentRunner } + +constructor TAgentRunner.Create(AProvider: ILLMProvider; + const AConfig: TAgentConfig; AObserver: IAgentObserver); +begin + inherited Create; + FProvider := AProvider; + FConfig := AConfig; + FObserver := AObserver; + FProviders := TObjectList.Create(True); +end; + +destructor TAgentRunner.Destroy; +begin + FProviders.Free; + inherited; +end; + +procedure TAgentRunner.RegisterProvider(AProvider: TMCPToolProvider); +begin + FProviders.Add(AProvider); +end; + +function TAgentRunner.BuildInputSchema(AMethod: TRttiMethod): string; +var + Ctx: TRttiContext; + JSchema, JProps, JParam: TJSONObject; + JRequired: TJSONArray; + Attr: TCustomAttribute; + ParamAttr: MCPParamAttribute; +begin + Ctx := TRttiContext.Create; + try + JProps := TJSONObject.Create; + JRequired := TJSONArray.Create; + + for Attr in AMethod.GetAttributes do + if Attr is MCPParamAttribute then + begin + ParamAttr := MCPParamAttribute(Attr); + + JParam := TJSONObject.Create; + JParam.AddPair('description', ParamAttr.Description); + case ParamAttr.ParamType of + ptString: JParam.AddPair('type', 'string'); + ptInteger: JParam.AddPair('type', 'integer'); + ptNumber: JParam.AddPair('type', 'number'); + ptBoolean: JParam.AddPair('type', 'boolean'); + end; + JProps.AddPair(ParamAttr.Name, JParam); + + if ParamAttr.Required then + JRequired.Add(ParamAttr.Name); + end; + + JSchema := TJSONObject.Create; + try + JSchema.AddPair('type', 'object'); + JSchema.AddPair('properties', JProps); + if JRequired.Count > 0 then + JSchema.AddPair('required', JRequired) + else + JRequired.Free; + + Result := JSchema.ToJSON; + finally + JSchema.Free; + end; + finally + Ctx.Free; + end; +end; + +function TAgentRunner.BuildToolSchemas: TArray; +var + Ctx: TRttiContext; + Provider: TMCPToolProvider; + Method: TRttiMethod; + ToolAttr: MCPToolAttribute; + Schemas: TList; + Schema: TToolSchema; +begin + Ctx := TRttiContext.Create; + Schemas := TList.Create; + try + for Provider in FProviders do + for Method in Ctx.GetType(Provider.ClassType).GetMethods do + begin + ToolAttr := Method.GetAttribute; + if ToolAttr = nil then Continue; + + Schema := Default(TToolSchema); + Schema.Name := ToolAttr.Name; + Schema.Description := ToolAttr.Description; + Schema.InputSchema := BuildInputSchema(Method); + Schemas.Add(Schema); + end; + + Result := Schemas.ToArray; + finally + Schemas.Free; + Ctx.Free; + end; +end; + +function TAgentRunner.ToolResultToText(const AResult: TMCPToolResult): string; +var + Item: TMCPContent; + Parts: TStringBuilder; +begin + Parts := TStringBuilder.Create; + try + for Item in AResult.Content do + if Item.ContentType = mctText then + begin + if Parts.Length > 0 then + Parts.Append(sLineBreak); + Parts.Append(Item.TextValue); + end; + + Result := Parts.ToString; + if AResult.IsError then + Result := '[Error] ' + Result; + finally + Parts.Free; + end; +end; + +function TAgentRunner.ExecuteTool(const AToolName, AArgsJson: string): string; +var + Ctx: TRttiContext; + Provider: TMCPToolProvider; + RttiType: TRttiType; + Method: TRttiMethod; + ToolAttr: MCPToolAttribute; + JArgs: TJSONObject; + InvokeResult: TValue; +begin + Ctx := TRttiContext.Create; + try + JArgs := TJSONObject.ParseJSONValue(AArgsJson) as TJSONObject; + if JArgs = nil then + JArgs := TJSONObject.Create; + try + for Provider in FProviders do + begin + RttiType := Ctx.GetType(Provider.ClassType); + for Method in RttiType.GetMethods do + begin + ToolAttr := Method.GetAttribute; + if (ToolAttr = nil) or (ToolAttr.Name <> AToolName) then + Continue; + + try + Provider.BeforeCall(AToolName, JArgs); + InvokeResult := Method.Invoke(Provider, [TValue.From(JArgs)]); + Provider.AfterCall(AToolName); + Exit(ToolResultToText(InvokeResult.AsType)); + except + on E: Exception do + Exit('[Error] ' + E.Message); + end; + end; + end; + + Result := '[Error: Tool not found: ' + AToolName + ']'; + finally + JArgs.Free; + end; + finally + Ctx.Free; + end; +end; + +function TAgentRunner.Run(const AUserInput: string): TAgentResult; +var + Messages: TList; + Schemas: TArray; + Response: TLLMResponse; + Iteration: Integer; + TC: TLLMToolCall; + ToolResultText: string; +begin + Result := Default(TAgentResult); + + Messages := TList.Create; + try + if FConfig.SystemPrompt <> '' then + Messages.Add(TLLMMessage.System(FConfig.SystemPrompt)); + Messages.Add(TLLMMessage.User(AUserInput)); + + Schemas := BuildToolSchemas; + + for Iteration := 1 to FConfig.MaxIterations do + begin + if Assigned(FObserver) then + FObserver.OnIterationStart(Iteration); + + try + Response := FProvider.Complete(Messages.ToArray, Schemas); + except + on E: Exception do + begin + Result.Success := False; + Result.Iterations := Iteration; + Result.ErrorMsg := E.Message; + Exit; + end; + end; + + if Assigned(FObserver) then + FObserver.OnLLMResponse(Response.Content, Response.StopReason); + + case Response.StopReason of + srEndTurn: + begin + if Assigned(FObserver) then + FObserver.OnFinished(Response.Content, Iteration); + Result.FinalAnswer := Response.Content; + Result.Success := True; + Result.Iterations := Iteration; + Exit; + end; + + srToolUse: + begin + Messages.Add(TLLMMessage.Assistant(Response.Content, Response.ToolCalls)); + + for TC in Response.ToolCalls do + begin + if Assigned(FObserver) then + FObserver.OnToolCall(TC.Name, TC.ArgsJson); + + ToolResultText := ExecuteTool(TC.Name, TC.ArgsJson); + + if Assigned(FObserver) then + FObserver.OnToolResult(TC.Name, ToolResultText); + + Messages.Add(TLLMMessage.ToolResult(TC.Id, ToolResultText)); + end; + end; + + srMaxTokens: + begin + Result.FinalAnswer := Response.Content; + Result.Success := False; + Result.Iterations := Iteration; + Result.ErrorMsg := 'Limite de tokens atingido'; + Exit; + end; + else + begin + Result.Success := False; + Result.ErrorMsg := 'Erro reportado pelo provider (srError)'; + Exit; + end; + end; + end; + + Result.Success := False; + Result.ErrorMsg := 'Limite de iterações atingido'; + finally + Messages.Free; + end; +end; + +end. diff --git a/Sources/AI/Agent/Providers/Dext.AI.Agent.Provider.Anthropic.pas b/Sources/AI/Agent/Providers/Dext.AI.Agent.Provider.Anthropic.pas new file mode 100644 index 00000000..7d1c0fca --- /dev/null +++ b/Sources/AI/Agent/Providers/Dext.AI.Agent.Provider.Anthropic.pas @@ -0,0 +1,323 @@ +{***************************************************************************} +{ } +{ Dext Framework } +{ } +{ Dext.AI.Agent - Multi-Provider LLM Agent } +{ } +{***************************************************************************} +{ } +{ Description: } +{ ILLMProvider implementation for the Anthropic Messages API. } +{ POST https://api.anthropic.com/v1/messages } +{ } +{ Anthropic requires every tool_result produced in reaction to a single } +{ assistant turn to be sent back as ONE user message whose content is } +{ an array of tool_result blocks. The Runner appends one lrToolResult } +{ TLLMMessage per tool call, so this provider coalesces any run of } +{ consecutive lrToolResult messages into a single user message when } +{ building the request body. } +{ } +{***************************************************************************} +unit Dext.AI.Agent.Provider.Anthropic; + +interface + +uses + System.SysUtils, + System.Classes, + System.JSON, + System.NetConsts, + System.Net.HttpClient, + System.Net.URLClient, + System.Generics.Collections, + Dext.AI.Agent.Contracts; + +type + TAnthropicProvider = class(TInterfacedObject, ILLMProvider) + private + FApiKey: string; + FModel: string; + FMaxTokens: Integer; + FEndpoint: string; + + function BuildRequestBody(const AMessages: TArray; + const ATools: TArray): TJSONObject; + function BuildToolJSON(const ATool: TToolSchema): TJSONObject; + function ParseResponse(const ABody: string): TLLMResponse; + function MapStopReason(const AReason: string): TLLMStopReason; + public + constructor Create(const AApiKey, AModel: string; AMaxTokens: Integer); + + function Complete( + const AMessages: TArray; + const ATools: TArray + ): TLLMResponse; + function ProviderName: string; + function ModelName: string; + end; + +implementation + +{ TAnthropicProvider } + +constructor TAnthropicProvider.Create(const AApiKey, AModel: string; AMaxTokens: Integer); +begin + inherited Create; + FApiKey := AApiKey; + FModel := AModel; + FMaxTokens := AMaxTokens; + FEndpoint := 'https://api.anthropic.com/v1/messages'; +end; + +function TAnthropicProvider.ProviderName: string; +begin + Result := 'anthropic'; +end; + +function TAnthropicProvider.ModelName: string; +begin + Result := FModel; +end; + +function TAnthropicProvider.BuildToolJSON(const ATool: TToolSchema): TJSONObject; +var + Schema: TJSONValue; +begin + Result := TJSONObject.Create; + Result.AddPair('name', ATool.Name); + Result.AddPair('description', ATool.Description); + + Schema := TJSONObject.ParseJSONValue(ATool.InputSchema); + if Schema = nil then + Schema := TJSONObject.Create; + Result.AddPair('input_schema', Schema); +end; + +function TAnthropicProvider.BuildRequestBody(const AMessages: TArray; + const ATools: TArray): TJSONObject; +var + MsgsArr, ToolsArr: TJSONArray; + ContentArr: TJSONArray; + MsgObj, ContentBlock: TJSONObject; + I, J: Integer; + Msg: TLLMMessage; + TC: TLLMToolCall; + Tool: TToolSchema; + ArgsVal: TJSONValue; +begin + Result := TJSONObject.Create; + Result.AddPair('model', FModel); + Result.AddPair('max_tokens', TJSONNumber.Create(FMaxTokens)); + + MsgsArr := TJSONArray.Create; + + I := 0; + while I < Length(AMessages) do + begin + Msg := AMessages[I]; + + case Msg.Role of + lrSystem: + begin + Result.AddPair('system', Msg.Content); + Inc(I); + end; + + lrUser: + begin + MsgObj := TJSONObject.Create; + MsgObj.AddPair('role', 'user'); + MsgObj.AddPair('content', Msg.Content); + MsgsArr.Add(MsgObj); + Inc(I); + end; + + lrAssistant: + begin + MsgObj := TJSONObject.Create; + MsgObj.AddPair('role', 'assistant'); + ContentArr := TJSONArray.Create; + + if Msg.Content <> '' then + begin + ContentBlock := TJSONObject.Create; + ContentBlock.AddPair('type', 'text'); + ContentBlock.AddPair('text', Msg.Content); + ContentArr.Add(ContentBlock); + end; + + for TC in Msg.ToolCalls do + begin + ContentBlock := TJSONObject.Create; + ContentBlock.AddPair('type', 'tool_use'); + ContentBlock.AddPair('id', TC.Id); + ContentBlock.AddPair('name', TC.Name); + ArgsVal := TJSONObject.ParseJSONValue(TC.ArgsJson); + if ArgsVal = nil then + ArgsVal := TJSONObject.Create; + ContentBlock.AddPair('input', ArgsVal); + ContentArr.Add(ContentBlock); + end; + + MsgObj.AddPair('content', ContentArr); + MsgsArr.Add(MsgObj); + Inc(I); + end; + + lrToolResult: + begin + // Coalesce this run of consecutive tool-result messages into a + // single {"role":"user","content":[tool_result, tool_result, ...]} + MsgObj := TJSONObject.Create; + MsgObj.AddPair('role', 'user'); + ContentArr := TJSONArray.Create; + + J := I; + while (J < Length(AMessages)) and (AMessages[J].Role = lrToolResult) do + begin + ContentBlock := TJSONObject.Create; + ContentBlock.AddPair('type', 'tool_result'); + ContentBlock.AddPair('tool_use_id', AMessages[J].ToolCallId); + ContentBlock.AddPair('content', AMessages[J].Content); + ContentArr.Add(ContentBlock); + Inc(J); + end; + + MsgObj.AddPair('content', ContentArr); + MsgsArr.Add(MsgObj); + I := J; + end; + else + Inc(I); + end; + end; + + Result.AddPair('messages', MsgsArr); + + if Length(ATools) > 0 then + begin + ToolsArr := TJSONArray.Create; + for Tool in ATools do + ToolsArr.Add(BuildToolJSON(Tool)); + Result.AddPair('tools', ToolsArr); + end; +end; + +function TAnthropicProvider.MapStopReason(const AReason: string): TLLMStopReason; +begin + if AReason = 'end_turn' then + Result := srEndTurn + else if AReason = 'tool_use' then + Result := srToolUse + else if AReason = 'max_tokens' then + Result := srMaxTokens + else + Result := srError; +end; + +function TAnthropicProvider.ParseResponse(const ABody: string): TLLMResponse; +var + Root, Usage, Block: TJSONObject; + ContentArr: TJSONArray; + I: Integer; + BlockType: string; + TextBuf: TStringBuilder; + ToolCalls: TList; + TC: TLLMToolCall; +begin + Result := Default(TLLMResponse); + + Root := TJSONObject.ParseJSONValue(ABody) as TJSONObject; + if Root = nil then + raise ELLMProviderError.CreateFmt('Anthropic: resposta inválida: %s', [ABody]); + try + ContentArr := Root.GetValue('content', nil); + if ContentArr = nil then + raise ELLMProviderError.CreateFmt('Anthropic: resposta sem content: %s', [ABody]); + + TextBuf := TStringBuilder.Create; + ToolCalls := TList.Create; + try + for I := 0 to ContentArr.Count - 1 do + begin + Block := ContentArr.Items[I] as TJSONObject; + BlockType := Block.GetValue('type', ''); + + if BlockType = 'text' then + TextBuf.Append(Block.GetValue('text', '')) + else if BlockType = 'tool_use' then + begin + TC := Default(TLLMToolCall); + TC.Id := Block.GetValue('id', ''); + TC.Name := Block.GetValue('name', ''); + if Block.GetValue('input') <> nil then + TC.ArgsJson := Block.GetValue('input').ToJSON + else + TC.ArgsJson := '{}'; + ToolCalls.Add(TC); + end; + end; + + Result.Content := TextBuf.ToString; + Result.ToolCalls := ToolCalls.ToArray; + finally + TextBuf.Free; + ToolCalls.Free; + end; + + Result.StopReason := MapStopReason(Root.GetValue('stop_reason', '')); + + Usage := Root.GetValue('usage', nil); + if Usage <> nil then + begin + Result.InputTokens := Usage.GetValue('input_tokens', 0); + Result.OutputTokens := Usage.GetValue('output_tokens', 0); + end; + finally + Root.Free; + end; +end; + +function TAnthropicProvider.Complete(const AMessages: TArray; + const ATools: TArray): TLLMResponse; +var + HttpClient: THTTPClient; + Body: TJSONObject; + Stream: TStringStream; + Response: IHTTPResponse; +begin + if FApiKey = '' then + raise ELLMProviderError.Create('Anthropic: API key não configurada.'); + + HttpClient := THTTPClient.Create; + try + HttpClient.ConnectionTimeout := 120000; + HttpClient.ResponseTimeout := 120000; + HttpClient.CustomHeaders['x-api-key'] := FApiKey; + HttpClient.CustomHeaders['anthropic-version'] := '2023-06-01'; + HttpClient.ContentType := 'application/json'; + + Body := BuildRequestBody(AMessages, ATools); + try + Stream := TStringStream.Create(Body.ToJSON, TEncoding.UTF8); + try + Response := HttpClient.Post(FEndpoint, Stream, nil, + [TNetHeader.Create('Content-Type', 'application/json')]); + finally + Stream.Free; + end; + finally + Body.Free; + end; + + if Response.StatusCode <> 200 then + raise ELLMProviderError.CreateFmt('Anthropic HTTP %d: %s', + [Response.StatusCode, Response.ContentAsString(TEncoding.UTF8)]); + + Result := ParseResponse(Response.ContentAsString(TEncoding.UTF8)); + finally + HttpClient.Free; + end; +end; + +end. diff --git a/Sources/AI/Agent/Providers/Dext.AI.Agent.Provider.Ollama.pas b/Sources/AI/Agent/Providers/Dext.AI.Agent.Provider.Ollama.pas new file mode 100644 index 00000000..a4acce09 --- /dev/null +++ b/Sources/AI/Agent/Providers/Dext.AI.Agent.Provider.Ollama.pas @@ -0,0 +1,269 @@ +{***************************************************************************} +{ } +{ Dext Framework } +{ } +{ Dext.AI.Agent - Multi-Provider LLM Agent } +{ } +{***************************************************************************} +{ } +{ Description: } +{ ILLMProvider implementation for a local Ollama server (OpenAI-style } +{ chat endpoint, no API key required). } +{ POST /api/chat } +{ } +{***************************************************************************} +unit Dext.AI.Agent.Provider.Ollama; + +interface + +uses + System.SysUtils, + System.Classes, + System.JSON, + System.NetConsts, + System.Net.HttpClient, + System.Net.URLClient, + System.Generics.Collections, + Dext.AI.Agent.Contracts; + +type + TOllamaProvider = class(TInterfacedObject, ILLMProvider) + private + FBaseUrl: string; + FModel: string; + FMaxTokens: Integer; + + function BuildRequestBody(const AMessages: TArray; + const ATools: TArray): TJSONObject; + function BuildMessageJSON(const AMessage: TLLMMessage): TJSONObject; + function BuildToolJSON(const ATool: TToolSchema): TJSONObject; + function ParseResponse(const ABody: string): TLLMResponse; + function MapDoneReason(const AReason: string): TLLMStopReason; + public + constructor Create(const ABaseUrl, AModel: string; AMaxTokens: Integer); + + function Complete( + const AMessages: TArray; + const ATools: TArray + ): TLLMResponse; + function ProviderName: string; + function ModelName: string; + end; + +implementation + +{ TOllamaProvider } + +constructor TOllamaProvider.Create(const ABaseUrl, AModel: string; AMaxTokens: Integer); +begin + inherited Create; + FBaseUrl := ABaseUrl.TrimRight(['/']); + FModel := AModel; + FMaxTokens := AMaxTokens; +end; + +function TOllamaProvider.ProviderName: string; +begin + Result := 'ollama'; +end; + +function TOllamaProvider.ModelName: string; +begin + Result := FModel; +end; + +function TOllamaProvider.BuildMessageJSON(const AMessage: TLLMMessage): TJSONObject; +var + ToolCallsArr: TJSONArray; + TC: TLLMToolCall; + TCObj, FnObj: TJSONObject; +begin + Result := TJSONObject.Create; + case AMessage.Role of + lrSystem: + begin + Result.AddPair('role', 'system'); + Result.AddPair('content', AMessage.Content); + end; + lrUser: + begin + Result.AddPair('role', 'user'); + Result.AddPair('content', AMessage.Content); + end; + lrAssistant: + begin + Result.AddPair('role', 'assistant'); + Result.AddPair('content', AMessage.Content); + + if Length(AMessage.ToolCalls) > 0 then + begin + ToolCallsArr := TJSONArray.Create; + for TC in AMessage.ToolCalls do + begin + TCObj := TJSONObject.Create; + FnObj := TJSONObject.Create; + FnObj.AddPair('name', TC.Name); + FnObj.AddPair('arguments', TC.ArgsJson); + TCObj.AddPair('function', FnObj); + ToolCallsArr.Add(TCObj); + end; + Result.AddPair('tool_calls', ToolCallsArr); + end; + end; + lrToolResult: + begin + Result.AddPair('role', 'tool'); + Result.AddPair('tool_call_id', AMessage.ToolCallId); + Result.AddPair('content', AMessage.Content); + end; + end; +end; + +function TOllamaProvider.BuildToolJSON(const ATool: TToolSchema): TJSONObject; +var + FnObj: TJSONObject; + Params: TJSONValue; +begin + Result := TJSONObject.Create; + Result.AddPair('type', 'function'); + + FnObj := TJSONObject.Create; + FnObj.AddPair('name', ATool.Name); + FnObj.AddPair('description', ATool.Description); + + Params := TJSONObject.ParseJSONValue(ATool.InputSchema); + if Params = nil then + Params := TJSONObject.Create; + FnObj.AddPair('parameters', Params); + + Result.AddPair('function', FnObj); +end; + +function TOllamaProvider.BuildRequestBody(const AMessages: TArray; + const ATools: TArray): TJSONObject; +var + MsgsArr, ToolsArr: TJSONArray; + Msg: TLLMMessage; + Tool: TToolSchema; +begin + Result := TJSONObject.Create; + Result.AddPair('model', FModel); + Result.AddPair('stream', TJSONBool.Create(False)); + + MsgsArr := TJSONArray.Create; + for Msg in AMessages do + MsgsArr.Add(BuildMessageJSON(Msg)); + Result.AddPair('messages', MsgsArr); + + if Length(ATools) > 0 then + begin + ToolsArr := TJSONArray.Create; + for Tool in ATools do + ToolsArr.Add(BuildToolJSON(Tool)); + Result.AddPair('tools', ToolsArr); + end; +end; + +function TOllamaProvider.MapDoneReason(const AReason: string): TLLMStopReason; +begin + if AReason = 'stop' then + Result := srEndTurn + else if AReason = 'tool_calls' then + Result := srToolUse + else + Result := srError; +end; + +function TOllamaProvider.ParseResponse(const ABody: string): TLLMResponse; +var + Root, Message, TCObj, FnObj: TJSONObject; + ToolCallsArr: TJSONArray; + ToolCalls: TArray; + I: Integer; + TC: TLLMToolCall; + ArgsVal: TJSONValue; +begin + Result := Default(TLLMResponse); + + Root := TJSONObject.ParseJSONValue(ABody) as TJSONObject; + if Root = nil then + raise ELLMProviderError.CreateFmt('Ollama: resposta inválida: %s', [ABody]); + try + Message := Root.GetValue('message', nil); + if Message = nil then + raise ELLMProviderError.CreateFmt('Ollama: resposta sem message: %s', [ABody]); + + Result.Content := Message.GetValue('content', ''); + + // 'tool_calls' is absent on a plain-text final answer - GetValue with a + // default is required here, the 1-arg overload raises EJSONException instead + // of returning nil when the key is missing. + ToolCallsArr := Message.GetValue('tool_calls', nil); + if ToolCallsArr <> nil then + begin + SetLength(ToolCalls, ToolCallsArr.Count); + for I := 0 to ToolCallsArr.Count - 1 do + begin + TCObj := ToolCallsArr.Items[I] as TJSONObject; + FnObj := TCObj.GetValue('function', nil); + TC := Default(TLLMToolCall); + TC.Id := 'ollama-call-' + IntToStr(I); + TC.Name := FnObj.GetValue('name', ''); + + ArgsVal := FnObj.GetValue('arguments'); + if ArgsVal <> nil then + TC.ArgsJson := ArgsVal.ToJSON + else + TC.ArgsJson := '{}'; + + ToolCalls[I] := TC; + end; + Result.ToolCalls := ToolCalls; + end; + + Result.StopReason := MapDoneReason(Root.GetValue('done_reason', '')); + Result.InputTokens := 0; + Result.OutputTokens := 0; + finally + Root.Free; + end; +end; + +function TOllamaProvider.Complete(const AMessages: TArray; + const ATools: TArray): TLLMResponse; +var + HttpClient: THTTPClient; + Body: TJSONObject; + Stream: TStringStream; + Response: IHTTPResponse; +begin + HttpClient := THTTPClient.Create; + try + HttpClient.ConnectionTimeout := 120000; + HttpClient.ResponseTimeout := 120000; + HttpClient.ContentType := 'application/json'; + + Body := BuildRequestBody(AMessages, ATools); + try + Stream := TStringStream.Create(Body.ToJSON, TEncoding.UTF8); + try + Response := HttpClient.Post(FBaseUrl + '/api/chat', Stream, nil, + [TNetHeader.Create('Content-Type', 'application/json')]); + finally + Stream.Free; + end; + finally + Body.Free; + end; + + if Response.StatusCode <> 200 then + raise ELLMProviderError.CreateFmt('Ollama HTTP %d: %s', + [Response.StatusCode, Response.ContentAsString(TEncoding.UTF8)]); + + Result := ParseResponse(Response.ContentAsString(TEncoding.UTF8)); + finally + HttpClient.Free; + end; +end; + +end. diff --git a/Sources/AI/Agent/Providers/Dext.AI.Agent.Provider.OpenAI.pas b/Sources/AI/Agent/Providers/Dext.AI.Agent.Provider.OpenAI.pas new file mode 100644 index 00000000..829786c8 --- /dev/null +++ b/Sources/AI/Agent/Providers/Dext.AI.Agent.Provider.OpenAI.pas @@ -0,0 +1,291 @@ +{***************************************************************************} +{ } +{ Dext Framework } +{ } +{ Dext.AI.Agent - Multi-Provider LLM Agent } +{ } +{***************************************************************************} +{ } +{ Description: } +{ ILLMProvider implementation for OpenAI's Chat Completions API. } +{ POST https://api.openai.com/v1/chat/completions } +{ } +{***************************************************************************} +unit Dext.AI.Agent.Provider.OpenAI; + +interface + +uses + System.SysUtils, + System.Classes, + System.JSON, + System.NetConsts, + System.Net.HttpClient, + System.Net.URLClient, + System.Generics.Collections, + Dext.AI.Agent.Contracts; + +type + TOpenAIProvider = class(TInterfacedObject, ILLMProvider) + private + FApiKey: string; + FModel: string; + FMaxTokens: Integer; + FEndpoint: string; + + function BuildRequestBody(const AMessages: TArray; + const ATools: TArray): TJSONObject; + function BuildMessageJSON(const AMessage: TLLMMessage): TJSONObject; + function BuildToolJSON(const ATool: TToolSchema): TJSONObject; + function ParseResponse(const ABody: string): TLLMResponse; + function MapFinishReason(const AReason: string): TLLMStopReason; + public + constructor Create(const AApiKey, AModel: string; AMaxTokens: Integer); + + function Complete( + const AMessages: TArray; + const ATools: TArray + ): TLLMResponse; + function ProviderName: string; + function ModelName: string; + end; + +implementation + +{ TOpenAIProvider } + +constructor TOpenAIProvider.Create(const AApiKey, AModel: string; AMaxTokens: Integer); +begin + inherited Create; + FApiKey := AApiKey; + FModel := AModel; + FMaxTokens := AMaxTokens; + FEndpoint := 'https://api.openai.com/v1/chat/completions'; +end; + +function TOpenAIProvider.ProviderName: string; +begin + Result := 'openai'; +end; + +function TOpenAIProvider.ModelName: string; +begin + Result := FModel; +end; + +function TOpenAIProvider.BuildMessageJSON(const AMessage: TLLMMessage): TJSONObject; +var + ToolCallsArr: TJSONArray; + TC: TLLMToolCall; + TCObj, FnObj: TJSONObject; +begin + Result := TJSONObject.Create; + case AMessage.Role of + lrSystem: + begin + Result.AddPair('role', 'system'); + Result.AddPair('content', AMessage.Content); + end; + lrUser: + begin + Result.AddPair('role', 'user'); + Result.AddPair('content', AMessage.Content); + end; + lrAssistant: + begin + Result.AddPair('role', 'assistant'); + if Length(AMessage.ToolCalls) > 0 then + begin + if AMessage.Content <> '' then + Result.AddPair('content', AMessage.Content) + else + Result.AddPair('content', TJSONNull.Create); + + ToolCallsArr := TJSONArray.Create; + for TC in AMessage.ToolCalls do + begin + TCObj := TJSONObject.Create; + TCObj.AddPair('id', TC.Id); + TCObj.AddPair('type', 'function'); + FnObj := TJSONObject.Create; + FnObj.AddPair('name', TC.Name); + FnObj.AddPair('arguments', TC.ArgsJson); + TCObj.AddPair('function', FnObj); + ToolCallsArr.Add(TCObj); + end; + Result.AddPair('tool_calls', ToolCallsArr); + end + else + Result.AddPair('content', AMessage.Content); + end; + lrToolResult: + begin + Result.AddPair('role', 'tool'); + Result.AddPair('tool_call_id', AMessage.ToolCallId); + Result.AddPair('content', AMessage.Content); + end; + end; +end; + +function TOpenAIProvider.BuildToolJSON(const ATool: TToolSchema): TJSONObject; +var + FnObj: TJSONObject; + Params: TJSONValue; +begin + Result := TJSONObject.Create; + Result.AddPair('type', 'function'); + + FnObj := TJSONObject.Create; + FnObj.AddPair('name', ATool.Name); + FnObj.AddPair('description', ATool.Description); + + Params := TJSONObject.ParseJSONValue(ATool.InputSchema); + if Params = nil then + Params := TJSONObject.Create; + FnObj.AddPair('parameters', Params); + + Result.AddPair('function', FnObj); +end; + +function TOpenAIProvider.BuildRequestBody(const AMessages: TArray; + const ATools: TArray): TJSONObject; +var + MsgsArr, ToolsArr: TJSONArray; + Msg: TLLMMessage; + Tool: TToolSchema; +begin + Result := TJSONObject.Create; + Result.AddPair('model', FModel); + Result.AddPair('max_tokens', TJSONNumber.Create(FMaxTokens)); + + MsgsArr := TJSONArray.Create; + for Msg in AMessages do + MsgsArr.Add(BuildMessageJSON(Msg)); + Result.AddPair('messages', MsgsArr); + + if Length(ATools) > 0 then + begin + ToolsArr := TJSONArray.Create; + for Tool in ATools do + ToolsArr.Add(BuildToolJSON(Tool)); + Result.AddPair('tools', ToolsArr); + end; +end; + +function TOpenAIProvider.MapFinishReason(const AReason: string): TLLMStopReason; +begin + if AReason = 'stop' then + Result := srEndTurn + else if AReason = 'tool_calls' then + Result := srToolUse + else if AReason = 'length' then + Result := srMaxTokens + else + Result := srError; +end; + +function TOpenAIProvider.ParseResponse(const ABody: string): TLLMResponse; +var + Root, Choice, Message, Usage, FnObj, TCObj: TJSONObject; + Choices, ToolCallsArr: TJSONArray; + FinishReason: string; + ToolCalls: TArray; + I: Integer; + TC: TLLMToolCall; + ContentVal: TJSONValue; +begin + Result := Default(TLLMResponse); + + Root := TJSONObject.ParseJSONValue(ABody) as TJSONObject; + if Root = nil then + raise ELLMProviderError.CreateFmt('OpenAI: resposta inválida: %s', [ABody]); + try + Choices := Root.GetValue('choices', nil); + if (Choices = nil) or (Choices.Count = 0) then + raise ELLMProviderError.CreateFmt('OpenAI: resposta sem choices: %s', [ABody]); + + Choice := Choices.Items[0] as TJSONObject; + FinishReason := Choice.GetValue('finish_reason', ''); + Message := Choice.GetValue('message', nil); + if Message = nil then + raise ELLMProviderError.CreateFmt('OpenAI: choice sem message: %s', [ABody]); + + ContentVal := Message.GetValue('content'); + if (ContentVal <> nil) and not (ContentVal is TJSONNull) then + Result.Content := ContentVal.Value; + + // 'tool_calls' is absent on a plain-text final answer - GetValue with a + // default is required here, the 1-arg overload raises EJSONException instead + // of returning nil when the key is missing. + ToolCallsArr := Message.GetValue('tool_calls', nil); + if ToolCallsArr <> nil then + begin + SetLength(ToolCalls, ToolCallsArr.Count); + for I := 0 to ToolCallsArr.Count - 1 do + begin + TCObj := ToolCallsArr.Items[I] as TJSONObject; + FnObj := TCObj.GetValue('function', nil); + TC := Default(TLLMToolCall); + TC.Id := TCObj.GetValue('id', ''); + TC.Name := FnObj.GetValue('name', ''); + TC.ArgsJson := FnObj.GetValue('arguments', '{}'); + ToolCalls[I] := TC; + end; + Result.ToolCalls := ToolCalls; + end; + + Result.StopReason := MapFinishReason(FinishReason); + + Usage := Root.GetValue('usage', nil); + if Usage <> nil then + begin + Result.InputTokens := Usage.GetValue('prompt_tokens', 0); + Result.OutputTokens := Usage.GetValue('completion_tokens', 0); + end; + finally + Root.Free; + end; +end; + +function TOpenAIProvider.Complete(const AMessages: TArray; + const ATools: TArray): TLLMResponse; +var + HttpClient: THTTPClient; + Body: TJSONObject; + Stream: TStringStream; + Response: IHTTPResponse; +begin + if FApiKey = '' then + raise ELLMProviderError.Create('OpenAI: API key não configurada.'); + + HttpClient := THTTPClient.Create; + try + HttpClient.ConnectionTimeout := 120000; + HttpClient.ResponseTimeout := 120000; + HttpClient.CustomHeaders['Authorization'] := 'Bearer ' + FApiKey; + HttpClient.ContentType := 'application/json'; + + Body := BuildRequestBody(AMessages, ATools); + try + Stream := TStringStream.Create(Body.ToJSON, TEncoding.UTF8); + try + Response := HttpClient.Post(FEndpoint, Stream, nil, + [TNetHeader.Create('Content-Type', 'application/json')]); + finally + Stream.Free; + end; + finally + Body.Free; + end; + + if Response.StatusCode <> 200 then + raise ELLMProviderError.CreateFmt('OpenAI HTTP %d: %s', + [Response.StatusCode, Response.ContentAsString(TEncoding.UTF8)]); + + Result := ParseResponse(Response.ContentAsString(TEncoding.UTF8)); + finally + HttpClient.Free; + end; +end; + +end. diff --git a/Sources/AI/Graph/Dext.AI.Graph.Checkpointer.pas b/Sources/AI/Graph/Dext.AI.Graph.Checkpointer.pas new file mode 100644 index 00000000..08711a89 --- /dev/null +++ b/Sources/AI/Graph/Dext.AI.Graph.Checkpointer.pas @@ -0,0 +1,152 @@ +{***************************************************************************} +{ } +{ Dext Framework } +{ } +{ Dext.AI.Graph - Orquestração de agentes estilo LangGraph } +{ } +{***************************************************************************} +{ } +{ Description: } +{ Checkpointers em memória (MemorySaver) e em arquivo JSON. } +{ } +{***************************************************************************} +unit Dext.AI.Graph.Checkpointer; + +interface + +uses + Dext.AI.Graph.Contracts, + System.Generics.Collections, + System.SysUtils; + +type + TMemoryCheckpointer = class(TInterfacedObject, ICheckpointer) + private + FStore: TDictionary; + public + constructor Create; + destructor Destroy; override; + procedure Save(const AThreadId: string; const AStateJson: string); + function Load(const AThreadId: string): string; + function Exists(const AThreadId: string): Boolean; + procedure Delete(const AThreadId: string); + end; + + TFileCheckpointer = class(TInterfacedObject, ICheckpointer) + private + FBasePath: string; + function FilePath(const AThreadId: string): string; + function SanitizeId(const AThreadId: string): string; + public + constructor Create(const ABasePath: string = ''); + procedure Save(const AThreadId: string; const AStateJson: string); + function Load(const AThreadId: string): string; + function Exists(const AThreadId: string): Boolean; + procedure Delete(const AThreadId: string); + end; + +implementation + +uses + System.IOUtils; + +{ TMemoryCheckpointer } + +constructor TMemoryCheckpointer.Create; +begin + inherited Create; + FStore := TDictionary.Create; +end; + +destructor TMemoryCheckpointer.Destroy; +begin + FStore.Free; + inherited; +end; + +procedure TMemoryCheckpointer.Save(const AThreadId: string; const AStateJson: string); +begin + FStore.AddOrSetValue(AThreadId, AStateJson); +end; + +function TMemoryCheckpointer.Load(const AThreadId: string): string; +begin + if not FStore.TryGetValue(AThreadId, Result) then + raise EGraphError.CreateFmt('Checkpoint não encontrado: %s', [AThreadId]); +end; + +function TMemoryCheckpointer.Exists(const AThreadId: string): Boolean; +begin + Result := FStore.ContainsKey(AThreadId); +end; + +procedure TMemoryCheckpointer.Delete(const AThreadId: string); +begin + FStore.Remove(AThreadId); +end; + +{ TFileCheckpointer } + +constructor TFileCheckpointer.Create(const ABasePath: string); +begin + inherited Create; + if ABasePath = '' then + FBasePath := TPath.Combine(TPath.GetTempPath, 'dext-ai-graph') + else + FBasePath := ABasePath; +end; + +function TFileCheckpointer.SanitizeId(const AThreadId: string): string; +var + I: Integer; + C: Char; +begin + Result := ''; + for I := 1 to Length(AThreadId) do + begin + C := AThreadId[I]; + if CharInSet(C, ['A'..'Z', 'a'..'z', '0'..'9', '-', '_']) then + Result := Result + C + else + Result := Result + '_'; + end; + if Result = '' then + Result := 'thread'; +end; + +function TFileCheckpointer.FilePath(const AThreadId: string): string; +begin + Result := TPath.Combine(FBasePath, SanitizeId(AThreadId) + '.json'); +end; + +procedure TFileCheckpointer.Save(const AThreadId: string; const AStateJson: string); +begin + TDirectory.CreateDirectory(FBasePath); + TFile.WriteAllText(FilePath(AThreadId), AStateJson, TEncoding.UTF8); +end; + +function TFileCheckpointer.Load(const AThreadId: string): string; +var + Path: string; +begin + Path := FilePath(AThreadId); + if not TFile.Exists(Path) then + raise EGraphError.CreateFmt('Checkpoint não encontrado: %s', [AThreadId]); + Result := TFile.ReadAllText(Path, TEncoding.UTF8); +end; + +function TFileCheckpointer.Exists(const AThreadId: string): Boolean; +begin + Result := TFile.Exists(FilePath(AThreadId)); +end; + +procedure TFileCheckpointer.Delete(const AThreadId: string); +var + Path: string; +begin + Path := FilePath(AThreadId); + if TFile.Exists(Path) then + TFile.Delete(Path); +end; + +end. diff --git a/Sources/AI/Graph/Dext.AI.Graph.Compiled.pas b/Sources/AI/Graph/Dext.AI.Graph.Compiled.pas new file mode 100644 index 00000000..2fde40e2 --- /dev/null +++ b/Sources/AI/Graph/Dext.AI.Graph.Compiled.pas @@ -0,0 +1,439 @@ +{***************************************************************************} +{ } +{ Dext Framework } +{ } +{ Dext.AI.Graph - Orquestração de agentes estilo LangGraph } +{ } +{***************************************************************************} +{ } +{ Description: } +{ TCompiledAgent — resultado de TAgentGraph.Compile(). } +{ Executa o grafo nó a nó, gerenciando estado e checkpoints. } +{ } +{***************************************************************************} +unit Dext.AI.Graph.Compiled; + +interface + +uses + System.SysUtils, + System.Generics.Collections, + Dext.AI.Graph.Contracts, + Dext.AI.Graph.State, + Dext.AI.Graph.Edge, + Dext.AI.Graph.Graph, + Dext.AI.Graph.Checkpointer, + Dext.AI.Agent.Contracts; + +type + TCompiledAgent = class(TInterfacedObject, ICompiledAgent) + private + FNodes: TDictionary; + FEdges: TList; + FEntryPoint: string; + FInterruptBefore: TArray; + FContext: TNodeContext; + FCheckpointer: ICheckpointer; + FMaxIterations: Integer; + FHeldState: TAgentState; + + function ExecuteNode( + const ANodeName: string; + const AState: TAgentState + ): TAgentState; + + function ResolveNextNode( + const ACurrentNode: string; + const AState: TAgentState + ): string; + + function ShouldInterrupt(const ANodeName: string): Boolean; + function GenerateThreadId: string; + procedure CheckpointSave(const AThreadId: string; AState: TAgentState); + function CheckpointLoad(const AThreadId: string): TAgentState; + function ExecuteLoop(AState: TAgentState; ASkipFirstInterrupt: Boolean): TGraphRunResult; + function DescribePending(AState: TAgentState; const ANode: string): string; + + // Executa este grafo compilado como um único nó de um grafo pai + // (subgraph-as-node): roda do próprio EntryPoint até o próprio + // GRAPH_END/IsDone e devolve o TAgentState resultante — sem produzir + // TGraphRunResult, sem checkpointing próprio (o pai é quem persiste). + function RunAsSubgraph(const AState: TAgentState): TAgentState; + public + constructor Create( + ANodes: TDictionary; + AEdges: TList; + const AEntryPoint: string; + const AInterruptBefore: TArray; + const AContext: TNodeContext; + ACheckpointer: ICheckpointer; + AMaxIterations: Integer + ); + destructor Destroy; override; + + function Run( + const AInput: string; + const AThreadId: string = '' + ): TGraphRunResult; + + function Resume(const AThreadId: string): TGraphRunResult; + procedure Cancel(const AThreadId: string); + function GetState(const AThreadId: string): TObject; + function AsNode: TNodeHandler; + end; + +implementation + +function ReplaceState(var Current: TAgentState; NewState: TAgentState): TAgentState; +begin + if (Current <> nil) and (Current <> NewState) then + Current.Free; + Current := NewState; + Result := Current; +end; + +{ TCompiledAgent } + +constructor TCompiledAgent.Create( + ANodes: TDictionary; + AEdges: TList; + const AEntryPoint: string; + const AInterruptBefore: TArray; + const AContext: TNodeContext; + ACheckpointer: ICheckpointer; + AMaxIterations: Integer +); +var + Pair: TPair; + Edge: TEdge; +begin + inherited Create; + FNodes := TDictionary.Create; + if ANodes <> nil then + for Pair in ANodes do + FNodes.Add(Pair.Key, Pair.Value); + + FEdges := TList.Create; + if AEdges <> nil then + for Edge in AEdges do + FEdges.Add(Edge); + + FEntryPoint := AEntryPoint; + FInterruptBefore := Copy(AInterruptBefore); + FContext := AContext; + FCheckpointer := ACheckpointer; + if AMaxIterations <= 0 then + FMaxIterations := 15 + else + FMaxIterations := AMaxIterations; +end; + +destructor TCompiledAgent.Destroy; +begin + FHeldState.Free; + FEdges.Free; + FNodes.Free; + inherited; +end; + +function TCompiledAgent.GenerateThreadId: string; +begin + Result := TGUID.NewGuid.ToString.Replace('{', '').Replace('}', '').ToLower; +end; + +procedure TCompiledAgent.CheckpointSave(const AThreadId: string; AState: TAgentState); +begin + if (FCheckpointer = nil) or (AState = nil) then + Exit; + FCheckpointer.Save(AThreadId, AState.ToJson); +end; + +function TCompiledAgent.CheckpointLoad(const AThreadId: string): TAgentState; +begin + if FCheckpointer = nil then + raise EGraphError.Create('Checkpointer não configurado'); + Result := TAgentState.FromJson(FCheckpointer.Load(AThreadId)); +end; + +function TCompiledAgent.ShouldInterrupt(const ANodeName: string): Boolean; +var + S: string; + Node: TGraphNode; +begin + for S in FInterruptBefore do + if S = ANodeName then + Exit(True); + if FNodes.TryGetValue(ANodeName, Node) and Node.RequiresApproval then + Exit(True); + Result := False; +end; + +function TCompiledAgent.DescribePending(AState: TAgentState; const ANode: string): string; +begin + if (AState <> nil) and AState.HasPendingCalls then + Result := Format('Executar tool "%s" no nó %s', [AState.PendingCalls[0].Name, ANode]) + else + Result := 'Aguardando aprovação para executar o nó ' + ANode; +end; + +function TCompiledAgent.ExecuteNode( + const ANodeName: string; + const AState: TAgentState +): TAgentState; +var + Node: TGraphNode; +begin + if not FNodes.TryGetValue(ANodeName, Node) then + raise ENodeNotFound.CreateFmt('Nó não encontrado: %s', [ANodeName]); + if not Assigned(Node.Handler) then + raise EGraphExecutionError.CreateFmt('Handler ausente no nó "%s"', [ANodeName]); + Result := Node.Handler(AState, FContext); + if Result = nil then + raise EGraphExecutionError.CreateFmt('Nó "%s" retornou estado nulo', [ANodeName]); +end; + +function TCompiledAgent.ResolveNextNode( + const ACurrentNode: string; + const AState: TAgentState +): string; +var + Edge: TEdge; +begin + for Edge in FEdges do + begin + if Edge.SourceNode <> ACurrentNode then + Continue; + if Edge.Kind = ekFixed then + Exit(Edge.TargetNode); + if not Assigned(Edge.Condition) then + raise EGraphExecutionError.CreateFmt( + 'Edge condicional sem condição a partir de "%s"', [ACurrentNode]); + Result := Edge.Condition(AState); + if Result = '' then + Result := GRAPH_END; + Exit; + end; + Result := GRAPH_END; +end; + +function TCompiledAgent.ExecuteLoop( + AState: TAgentState; + ASkipFirstInterrupt: Boolean +): TGraphRunResult; +var + State: TAgentState; + NewState: TAgentState; + CurrentNode, NextNode: string; + I: Integer; +begin + State := AState; + Result := Default(TGraphRunResult); + if State <> nil then + Result.ThreadId := State.ThreadId; + + try + for I := 1 to FMaxIterations do + begin + if Assigned(FContext.Observer) then + FContext.Observer.OnIterationStart(I); + + CurrentNode := State.CurrentNode; + Result.Iterations := State.Iteration; + + if (CurrentNode = GRAPH_END) or (CurrentNode = '') then + begin + Result.Status := grsFinished; + Result.FinalAnswer := State.FinalAnswer; + if Assigned(FContext.Observer) then + FContext.Observer.OnFinished(Result.FinalAnswer, Result.Iterations); + Exit; + end; + + if (not ASkipFirstInterrupt) and ShouldInterrupt(CurrentNode) then + begin + CheckpointSave(State.ThreadId, State); + Result.Status := grsWaitingApproval; + Result.ThreadId := State.ThreadId; + Result.PendingNode := CurrentNode; + Result.PendingAction := DescribePending(State, CurrentNode); + Exit; + end; + ASkipFirstInterrupt := False; + + try + NewState := ExecuteNode(CurrentNode, State); + except + on E: Exception do + begin + Result.Status := grsError; + Result.ErrorMsg := E.Message; + Exit; + end; + end; + ReplaceState(State, NewState); + CheckpointSave(State.ThreadId, State); + + if State.IsDone then + begin + Result.Status := grsFinished; + Result.FinalAnswer := State.FinalAnswer; + Result.Iterations := State.Iteration; + if Assigned(FContext.Observer) then + FContext.Observer.OnFinished(Result.FinalAnswer, Result.Iterations); + Exit; + end; + + NextNode := ResolveNextNode(CurrentNode, State); + NewState := State.WithCurrentNode(NextNode); + ReplaceState(State, NewState); + NewState := State.NextIteration; + ReplaceState(State, NewState); + end; + + Result.Status := grsError; + Result.ErrorMsg := 'Limite de iterações atingido'; + Result.Iterations := FMaxIterations; + finally + State.Free; + end; +end; + +function TCompiledAgent.Run( + const AInput: string; + const AThreadId: string +): TGraphRunResult; +var + ThreadId: string; + State: TAgentState; +begin + if AThreadId = '' then + ThreadId := GenerateThreadId + else + ThreadId := AThreadId; + + if (FCheckpointer <> nil) and FCheckpointer.Exists(ThreadId) then + begin + State := CheckpointLoad(ThreadId); + // Só preserva o estado tal como está quando ele estiver genuinamente + // pausado num nó de aprovação (aguardando Resume). Em qualquer outro + // caso — concluído, em GRAPH_END, ou "preso" por um erro de execução + // anterior — trata como um novo turno, sob risco de descartar + // silenciosamente o AInput do usuário. + if State.IsDone or (State.CurrentNode = GRAPH_END) or (State.CurrentNode = '') + or not ShouldInterrupt(State.CurrentNode) then + begin + ReplaceState(State, State.RestartAt(FEntryPoint)); + ReplaceState(State, State.WithMessage(TLLMMessage.User(AInput))); + end; + end + else + begin + State := TAgentState.Create(ThreadId); + if FContext.Config.SystemPrompt <> '' then + ReplaceState(State, State.WithMessage(TLLMMessage.System(FContext.Config.SystemPrompt))); + ReplaceState(State, State.WithMessage(TLLMMessage.User(AInput))); + ReplaceState(State, State.WithCurrentNode(FEntryPoint)); + end; + + Result := ExecuteLoop(State, False); +end; + +function TCompiledAgent.Resume(const AThreadId: string): TGraphRunResult; +var + State: TAgentState; +begin + if (FCheckpointer = nil) or not FCheckpointer.Exists(AThreadId) then + raise EGraphError.CreateFmt('Nenhuma execução pausada para a thread %s', [AThreadId]); + State := CheckpointLoad(AThreadId); + Result := ExecuteLoop(State, True); +end; + +procedure TCompiledAgent.Cancel(const AThreadId: string); +begin + if FCheckpointer <> nil then + FCheckpointer.Delete(AThreadId); +end; + +function TCompiledAgent.GetState(const AThreadId: string): TObject; +begin + FreeAndNil(FHeldState); + if (FCheckpointer = nil) or not FCheckpointer.Exists(AThreadId) then + Exit(nil); + FHeldState := CheckpointLoad(AThreadId); + Result := FHeldState; +end; + +function TCompiledAgent.RunAsSubgraph(const AState: TAgentState): TAgentState; +var + State, NewState: TAgentState; + CurrentNode, NextNode: string; + I: Integer; + Finished: Boolean; +begin + // Entra pelo próprio EntryPoint do subgrafo, preservando mensagens/ + // metadata/threadId do estado do pai — TAgentState é o mesmo tipo + // concreto nos dois grafos, então não há tradução de schema a fazer. + State := AState.RestartAt(FEntryPoint); + Finished := False; + try + for I := 1 to FMaxIterations do + begin + if Assigned(FContext.Observer) then + FContext.Observer.OnIterationStart(I); + + CurrentNode := State.CurrentNode; + if (CurrentNode = GRAPH_END) or (CurrentNode = '') then + begin + Finished := True; + Break; + end; + + NewState := ExecuteNode(CurrentNode, State); + ReplaceState(State, NewState); + + if State.IsDone then + begin + Finished := True; + Break; + end; + + NextNode := ResolveNextNode(CurrentNode, State); + NewState := State.WithCurrentNode(NextNode); + ReplaceState(State, NewState); + NewState := State.NextIteration; + ReplaceState(State, NewState); + end; + + if not Finished then + raise EGraphExecutionError.CreateFmt( + 'Subgrafo excedeu o limite de %d iterações sem atingir GRAPH_END', [FMaxIterations]); + + // IsDone aqui é um sinal interno do subgrafo, não do grafo pai — o + // pai decide o que acontece depois via suas próprias edges a partir + // do nó que envolve este subgrafo. + NewState := State.ClearDone; + ReplaceState(State, NewState); + + Result := State; + State := nil; + finally + State.Free; + end; +end; + +function TCompiledAgent.AsNode: TNodeHandler; +begin + if Length(FInterruptBefore) > 0 then + raise EGraphCompileError.Create( + 'Grafos com RequireApproval/InterruptBefore não podem ser usados como ' + + 'subgrafo (AsNode) — aprovação humana aninhada não é suportada. ' + + 'Configure RequireApproval no nó do grafo pai que invoca este subgrafo.'); + + Result := + function(const AState: TAgentState; const ACtx: TNodeContext): TAgentState + begin + Result := Self.RunAsSubgraph(AState); + end; +end; + +end. diff --git a/Sources/AI/Graph/Dext.AI.Graph.Contracts.pas b/Sources/AI/Graph/Dext.AI.Graph.Contracts.pas new file mode 100644 index 00000000..4514747c --- /dev/null +++ b/Sources/AI/Graph/Dext.AI.Graph.Contracts.pas @@ -0,0 +1,96 @@ +{***************************************************************************} +{ } +{ Dext Framework } +{ } +{ Dext.AI.Graph - Orquestração de agentes estilo LangGraph } +{ } +{***************************************************************************} +{ } +{ Description: } +{ Tipos, constantes e interfaces base do Dext.AI.Graph. } +{ Equivalente ao core do LangGraph (StateGraph / CompiledGraph). } +{ } +{***************************************************************************} +unit Dext.AI.Graph.Contracts; + +interface + +uses + System.SysUtils, + Dext.AI.Agent.Contracts, + Dext.AI.Graph.State; + +const + GRAPH_END = '__end__'; + GRAPH_START = '__start__'; + +type + TGraphRunStatus = ( + grsRunning, + grsFinished, + grsWaitingApproval, + grsError, + grsCancelled + ); + + TGraphRunResult = record + Status: TGraphRunStatus; + FinalAnswer: string; + ThreadId: string; + Iterations: Integer; + ErrorMsg: string; + PendingNode: string; + PendingAction: string; + end; + + // Contexto de execução compartilhado, injetado em cada nó do grafo. + TNodeContext = record + Provider: ILLMProvider; + Config: TAgentConfig; + Observer: IAgentObserver; + end; + + // Handler de nó: recebe o estado atual e devolve o novo estado. + // Declarado aqui (não em Dext.AI.Graph.Graph) porque ICompiledAgent.AsNode + // precisa expor esse tipo, e Contracts não pode depender de Graph. + TNodeHandler = reference to function( + const AState: TAgentState; + const ACtx: TNodeContext + ): TAgentState; + + ICompiledAgent = interface + ['{C3D4E5F6-A7B8-9012-CDEF-123456789012}'] + function Run( + const AInput: string; + const AThreadId: string = '' + ): TGraphRunResult; + + function Resume(const AThreadId: string): TGraphRunResult; + procedure Cancel(const AThreadId: string); + function GetState(const AThreadId: string): TObject; + + // Adapta este grafo compilado para ser usado como um nó comum de um + // grafo pai (subgraph-as-node). O estado é passado direto — sem + // tradução — pois TAgentState já é o mesmo tipo em ambos os grafos. + // Grafos com RequireApproval/InterruptBefore levantam EGraphCompileError + // aqui: aprovação humana aninhada não é suportada (v1). + function AsNode: TNodeHandler; + end; + + ICheckpointer = interface + ['{D4E5F6A7-B8C9-0123-DEFA-234567890123}'] + procedure Save(const AThreadId: string; const AStateJson: string); + function Load(const AThreadId: string): string; + function Exists(const AThreadId: string): Boolean; + procedure Delete(const AThreadId: string); + end; + + EGraphError = class(Exception); + EGraphCompileError = class(EGraphError); + EGraphExecutionError = class(EGraphError); + ENodeNotFound = class(EGraphError); + ECycleDetected = class(EGraphError); + +implementation + +end. diff --git a/Sources/AI/Graph/Dext.AI.Graph.Edge.pas b/Sources/AI/Graph/Dext.AI.Graph.Edge.pas new file mode 100644 index 00000000..18e46f9b --- /dev/null +++ b/Sources/AI/Graph/Dext.AI.Graph.Edge.pas @@ -0,0 +1,91 @@ +{***************************************************************************} +{ } +{ Dext Framework } +{ } +{ Dext.AI.Graph - Orquestração de agentes estilo LangGraph } +{ } +{***************************************************************************} +{ } +{ Description: } +{ TEdge, TEdgeRoute e TEdgeCondition — edges do grafo. } +{ } +{***************************************************************************} +unit Dext.AI.Graph.Edge; + +interface + +uses + System.SysUtils, + Dext.AI.Graph.Contracts, + Dext.AI.Graph.State; + +type + TEdgeCondition = reference to function( + const AState: TAgentState + ): string; + + TEdgeRoute = record + TargetNode: string; + public + class function To_(const ANode: string): TEdgeRoute; static; + class function ToEnd: TEdgeRoute; static; + end; + + TEdgeKind = (ekFixed, ekConditional); + + TEdge = record + Kind: TEdgeKind; + SourceNode: string; + TargetNode: string; + Condition: TEdgeCondition; + Routes: TArray; + public + class function Fixed( + const ASource, ATarget: string + ): TEdge; static; + + class function Conditional( + const ASource: string; + ACondition: TEdgeCondition; + const ARoutes: TArray + ): TEdge; static; + end; + +implementation + +{ TEdgeRoute } + +class function TEdgeRoute.To_(const ANode: string): TEdgeRoute; +begin + Result.TargetNode := ANode; +end; + +class function TEdgeRoute.ToEnd: TEdgeRoute; +begin + Result.TargetNode := GRAPH_END; +end; + +{ TEdge } + +class function TEdge.Fixed(const ASource, ATarget: string): TEdge; +begin + Result := Default(TEdge); + Result.Kind := ekFixed; + Result.SourceNode := ASource; + Result.TargetNode := ATarget; +end; + +class function TEdge.Conditional( + const ASource: string; + ACondition: TEdgeCondition; + const ARoutes: TArray +): TEdge; +begin + Result := Default(TEdge); + Result.Kind := ekConditional; + Result.SourceNode := ASource; + Result.Condition := ACondition; + Result.Routes := ARoutes; +end; + +end. diff --git a/Sources/AI/Graph/Dext.AI.Graph.Graph.pas b/Sources/AI/Graph/Dext.AI.Graph.Graph.pas new file mode 100644 index 00000000..9cbc133e --- /dev/null +++ b/Sources/AI/Graph/Dext.AI.Graph.Graph.pas @@ -0,0 +1,333 @@ +{***************************************************************************} +{ } +{ Dext Framework } +{ } +{ Dext.AI.Graph - Orquestração de agentes estilo LangGraph } +{ } +{***************************************************************************} +{ } +{ Description: } +{ TAgentGraph — o StateGraph do Delphi. Define nós, edges e compila } +{ em ICompiledAgent. } +{ } +{***************************************************************************} +unit Dext.AI.Graph.Graph; + +interface + +uses + System.SysUtils, + System.Generics.Collections, + Dext.AI.Graph.Contracts, + Dext.AI.Graph.State, + Dext.AI.Graph.Edge, + Dext.AI.Agent.Contracts; + +type + // TNodeContext e TNodeHandler agora vivem em Dext.AI.Graph.Contracts + // (ICompiledAgent.AsNode precisa do tipo, e Contracts não pode depender + // desta unit). Ficam visíveis aqui via o uses acima. + + TGraphNode = record + Name: string; + Handler: TNodeHandler; + RequiresApproval: Boolean; + end; + + TAgentGraph = class + private + FNodes: TDictionary; + FEdges: TList; + FEntryPoint: string; + FInterruptBefore: TArray; + + procedure ValidateEntryPoint; + procedure ValidateNodesExist; + procedure ValidateReachability; + function FindNode(const AName: string): TGraphNode; + function CollectInterrupts: TArray; + function IsSpecialNode(const AName: string): Boolean; + public + constructor Create; + destructor Destroy; override; + + function AddNode( + const AName: string; + AHandler: TNodeHandler; + ARequiresApproval: Boolean = False + ): TAgentGraph; + + function SetEntryPoint(const ANode: string): TAgentGraph; + + function AddEdge( + const AFrom, ATo: string + ): TAgentGraph; + + function AddConditionalEdge( + const AFrom: string; + ACondition: TEdgeCondition; + const ARoutes: TArray + ): TAgentGraph; + + function Compile( + AProvider: ILLMProvider; + const AConfig: TAgentConfig; + AObserver: IAgentObserver = nil; + ACheckpointer: ICheckpointer = nil + ): ICompiledAgent; + + function InterruptBefore(const ANodes: TArray): TAgentGraph; + function RequireApproval(const ANode: string): TAgentGraph; + end; + +implementation + +uses + Dext.AI.Graph.Compiled, + Dext.AI.Graph.Checkpointer; + +{ TAgentGraph } + +constructor TAgentGraph.Create; +begin + inherited Create; + FNodes := TDictionary.Create; + FEdges := TList.Create; +end; + +destructor TAgentGraph.Destroy; +begin + FEdges.Free; + FNodes.Free; + inherited; +end; + +function TAgentGraph.IsSpecialNode(const AName: string): Boolean; +begin + Result := (AName = GRAPH_END) or (AName = GRAPH_START); +end; + +function TAgentGraph.FindNode(const AName: string): TGraphNode; +begin + if not FNodes.TryGetValue(AName, Result) then + raise ENodeNotFound.CreateFmt('Nó não encontrado: %s', [AName]); +end; + +function TAgentGraph.AddNode( + const AName: string; + AHandler: TNodeHandler; + ARequiresApproval: Boolean +): TAgentGraph; +var + Node: TGraphNode; +begin + if AName.Trim = '' then + raise EGraphCompileError.Create('Nome de nó vazio'); + if IsSpecialNode(AName) then + raise EGraphCompileError.CreateFmt('Nome reservado: %s', [AName]); + if not Assigned(AHandler) then + raise EGraphCompileError.CreateFmt('Handler ausente para o nó "%s"', [AName]); + if FNodes.ContainsKey(AName) then + raise EGraphCompileError.CreateFmt('Nó duplicado: %s', [AName]); + + Node.Name := AName; + Node.Handler := AHandler; + Node.RequiresApproval := ARequiresApproval; + FNodes.Add(AName, Node); + Result := Self; +end; + +function TAgentGraph.SetEntryPoint(const ANode: string): TAgentGraph; +begin + FEntryPoint := ANode; + Result := Self; +end; + +function TAgentGraph.AddEdge(const AFrom, ATo: string): TAgentGraph; +begin + FEdges.Add(TEdge.Fixed(AFrom, ATo)); + Result := Self; +end; + +function TAgentGraph.AddConditionalEdge( + const AFrom: string; + ACondition: TEdgeCondition; + const ARoutes: TArray +): TAgentGraph; +begin + if not Assigned(ACondition) then + raise EGraphCompileError.CreateFmt( + 'Condição ausente na edge condicional de "%s"', [AFrom]); + FEdges.Add(TEdge.Conditional(AFrom, ACondition, ARoutes)); + Result := Self; +end; + +function TAgentGraph.InterruptBefore(const ANodes: TArray): TAgentGraph; +begin + FInterruptBefore := Copy(ANodes); + Result := Self; +end; + +function TAgentGraph.RequireApproval(const ANode: string): TAgentGraph; +var + Node: TGraphNode; +begin + Node := FindNode(ANode); + Node.RequiresApproval := True; + FNodes.AddOrSetValue(ANode, Node); + Result := Self; +end; + +procedure TAgentGraph.ValidateEntryPoint; +begin + if FEntryPoint.Trim = '' then + raise EGraphCompileError.Create('Ponto de entrada não definido. Use SetEntryPoint.'); + if not FNodes.ContainsKey(FEntryPoint) then + raise ENodeNotFound.CreateFmt('Ponto de entrada inexistente: %s', [FEntryPoint]); +end; + +procedure TAgentGraph.ValidateNodesExist; +var + Edge: TEdge; + Route: TEdgeRoute; +begin + for Edge in FEdges do + begin + if not IsSpecialNode(Edge.SourceNode) and not FNodes.ContainsKey(Edge.SourceNode) then + raise ENodeNotFound.CreateFmt( + 'Edge referencia nó de origem inexistente: %s', [Edge.SourceNode]); + + if Edge.Kind = ekFixed then + begin + if not IsSpecialNode(Edge.TargetNode) and not FNodes.ContainsKey(Edge.TargetNode) then + raise ENodeNotFound.CreateFmt( + 'Edge referencia nó de destino inexistente: %s', [Edge.TargetNode]); + end + else + for Route in Edge.Routes do + if not IsSpecialNode(Route.TargetNode) and not FNodes.ContainsKey(Route.TargetNode) then + raise ENodeNotFound.CreateFmt( + 'Rota condicional referencia nó inexistente: %s', [Route.TargetNode]); + end; +end; + +procedure TAgentGraph.ValidateReachability; +var + Reachable: TDictionary; + Queue: TQueue; + Current: string; + Edge: TEdge; + Route: TEdgeRoute; + ReachedEnd: Boolean; + HasOutgoing: Boolean; + + procedure Visit(const ANode: string); + begin + if ANode = GRAPH_END then + begin + ReachedEnd := True; + Exit; + end; + if IsSpecialNode(ANode) then + Exit; + if Reachable.ContainsKey(ANode) then + Exit; + Reachable.Add(ANode, True); + Queue.Enqueue(ANode); + end; + +begin + if FNodes.Count = 0 then + raise EGraphCompileError.Create('Grafo sem nós'); + + Reachable := TDictionary.Create; + Queue := TQueue.Create; + try + ReachedEnd := False; + Visit(FEntryPoint); + + while Queue.Count > 0 do + begin + Current := Queue.Dequeue; + HasOutgoing := False; + for Edge in FEdges do + begin + if Edge.SourceNode <> Current then + Continue; + HasOutgoing := True; + if Edge.Kind = ekFixed then + Visit(Edge.TargetNode) + else + for Route in Edge.Routes do + Visit(Route.TargetNode); + end; + // Um nó sem nenhuma edge de saída termina implicitamente em + // GRAPH_END em runtime (ResolveNextNode faz esse fallback) — a + // validação precisa refletir o mesmo comportamento, senão rejeita + // grafos mínimos válidos de um único nó terminal. + if not HasOutgoing then + ReachedEnd := True; + end; + + if not ReachedEnd then + raise ECycleDetected.Create( + 'Nenhum caminho do ponto de entrada até GRAPH_END'); + finally + Queue.Free; + Reachable.Free; + end; +end; + +function TAgentGraph.CollectInterrupts: TArray; +var + List: TList; + Pair: TPair; + S: string; +begin + List := TList.Create; + try + for S in FInterruptBefore do + if (S <> '') and (List.IndexOf(S) < 0) then + List.Add(S); + for Pair in FNodes do + if Pair.Value.RequiresApproval and (List.IndexOf(Pair.Key) < 0) then + List.Add(Pair.Key); + Result := List.ToArray; + finally + List.Free; + end; +end; + +function TAgentGraph.Compile( + AProvider: ILLMProvider; + const AConfig: TAgentConfig; + AObserver: IAgentObserver; + ACheckpointer: ICheckpointer +): ICompiledAgent; +var + Ctx: TNodeContext; + MaxIter: Integer; +begin + if AProvider = nil then + raise EGraphCompileError.Create('Provider LLM é obrigatório'); + + ValidateEntryPoint; + ValidateNodesExist; + ValidateReachability; + + Ctx.Provider := AProvider; + Ctx.Config := AConfig; + Ctx.Observer := AObserver; + + if ACheckpointer = nil then + ACheckpointer := TMemoryCheckpointer.Create; + + MaxIter := AConfig.MaxIterations; + if MaxIter <= 0 then + MaxIter := 15; + + Result := TCompiledAgent.Create( + FNodes, FEdges, FEntryPoint, CollectInterrupts, Ctx, ACheckpointer, MaxIter); +end; + +end. diff --git a/Sources/AI/Graph/Dext.AI.Graph.State.pas b/Sources/AI/Graph/Dext.AI.Graph.State.pas new file mode 100644 index 00000000..5c474629 --- /dev/null +++ b/Sources/AI/Graph/Dext.AI.Graph.State.pas @@ -0,0 +1,435 @@ +{***************************************************************************} +{ } +{ Dext Framework } +{ } +{ Dext.AI.Graph - Orquestração de agentes estilo LangGraph } +{ } +{***************************************************************************} +{ } +{ Description: } +{ TAgentState — estado imutável que flui pelo grafo. } +{ Cada With* retorna uma NOVA instância. Nenhum método muta Self. } +{ } +{***************************************************************************} +unit Dext.AI.Graph.State; + +interface + +uses + System.SysUtils, + System.JSON, + System.Generics.Collections, + Dext.AI.Agent.Contracts; + +type + TAgentState = class + private + FMessages: TArray; + FPendingCalls: TArray; + FCurrentNode: string; + FIteration: Integer; + FIsDone: Boolean; + FFinalAnswer: string; + FMetadata: TDictionary; + FThreadId: string; + + constructor CreateInternal( + const AMessages: TArray; + const APendingCalls: TArray; + const ACurrentNode: string; + AIteration: Integer; + AIsDone: Boolean; + const AFinalAnswer: string; + AMetadata: TDictionary; + const AThreadId: string + ); + function CloneMetadata: TDictionary; + function CloneMessages: TArray; + function ClonePendingCalls: TArray; + public + constructor Create(const AThreadId: string = ''); + destructor Destroy; override; + + function WithMessage(const AMsg: TLLMMessage): TAgentState; + function WithMessages(const AMsgs: TArray): TAgentState; + function WithPendingCalls(const ACalls: TArray): TAgentState; + function ClearPendingCalls: TAgentState; + function WithCurrentNode(const ANode: string): TAgentState; + function WithIteration(AIteration: Integer): TAgentState; + function NextIteration: TAgentState; + function AsDone(const AAnswer: string): TAgentState; + function WithMeta(const AKey, AValue: string): TAgentState; + function RestartAt(const ANode: string): TAgentState; + function ClearDone: TAgentState; + + function ToJson: string; + class function FromJson(const AJson: string): TAgentState; static; + + property Messages: TArray read FMessages; + property PendingCalls: TArray read FPendingCalls; + property CurrentNode: string read FCurrentNode; + property Iteration: Integer read FIteration; + property IsDone: Boolean read FIsDone; + property FinalAnswer: string read FFinalAnswer; + property ThreadId: string read FThreadId; + + function HasPendingCalls: Boolean; + function LastMessage: TLLMMessage; + function GetMeta(const AKey: string; const ADefault: string = ''): string; + end; + +implementation + +function RoleToName(ARole: TLLMRole): string; +begin + case ARole of + lrSystem: Result := 'system'; + lrUser: Result := 'user'; + lrAssistant: Result := 'assistant'; + lrToolResult: Result := 'tool'; + else + Result := 'user'; + end; +end; + +function NameToRole(const AName: string): TLLMRole; +var + LName: string; +begin + LName := AName.ToLower; + if LName = 'system' then + Result := lrSystem + else if LName = 'assistant' then + Result := lrAssistant + else if (LName = 'tool') or (LName = 'toolresult') then + Result := lrToolResult + else + Result := lrUser; +end; + +function MessageToJson(const AMsg: TLLMMessage): TJSONObject; +var + JCalls: TJSONArray; + JCall: TJSONObject; + TC: TLLMToolCall; +begin + Result := TJSONObject.Create; + Result.AddPair('role', RoleToName(AMsg.Role)); + Result.AddPair('content', AMsg.Content); + Result.AddPair('toolCallId', AMsg.ToolCallId); + JCalls := TJSONArray.Create; + for TC in AMsg.ToolCalls do + begin + JCall := TJSONObject.Create; + JCall.AddPair('id', TC.Id); + JCall.AddPair('name', TC.Name); + JCall.AddPair('argsJson', TC.ArgsJson); + JCalls.Add(JCall); + end; + Result.AddPair('toolCalls', JCalls); +end; + +function JsonToMessage(AObj: TJSONObject): TLLMMessage; +var + JCalls: TJSONArray; + JCallObj: TJSONObject; + TC: TLLMToolCall; + Calls: TArray; + I: Integer; +begin + Result := Default(TLLMMessage); + Result.Role := NameToRole(AObj.GetValue('role', 'user')); + Result.Content := AObj.GetValue('content', ''); + Result.ToolCallId := AObj.GetValue('toolCallId', ''); + JCalls := AObj.GetValue('toolCalls') as TJSONArray; + if JCalls = nil then + Exit; + SetLength(Calls, JCalls.Count); + for I := 0 to JCalls.Count - 1 do + begin + JCallObj := JCalls.Items[I] as TJSONObject; + TC := Default(TLLMToolCall); + TC.Id := JCallObj.GetValue('id', ''); + TC.Name := JCallObj.GetValue('name', ''); + TC.ArgsJson := JCallObj.GetValue('argsJson', ''); + Calls[I] := TC; + end; + Result.ToolCalls := Calls; +end; + +function ToolCallToJson(const ATC: TLLMToolCall): TJSONObject; +begin + Result := TJSONObject.Create; + Result.AddPair('id', ATC.Id); + Result.AddPair('name', ATC.Name); + Result.AddPair('argsJson', ATC.ArgsJson); +end; + +function JsonToToolCall(AObj: TJSONObject): TLLMToolCall; +begin + Result := Default(TLLMToolCall); + Result.Id := AObj.GetValue('id', ''); + Result.Name := AObj.GetValue('name', ''); + Result.ArgsJson := AObj.GetValue('argsJson', ''); +end; + +{ TAgentState } + +constructor TAgentState.Create(const AThreadId: string); +begin + inherited Create; + FThreadId := AThreadId; + FMetadata := TDictionary.Create; +end; + +constructor TAgentState.CreateInternal( + const AMessages: TArray; + const APendingCalls: TArray; + const ACurrentNode: string; + AIteration: Integer; + AIsDone: Boolean; + const AFinalAnswer: string; + AMetadata: TDictionary; + const AThreadId: string +); +begin + inherited Create; + FMessages := AMessages; + FPendingCalls := APendingCalls; + FCurrentNode := ACurrentNode; + FIteration := AIteration; + FIsDone := AIsDone; + FFinalAnswer := AFinalAnswer; + FThreadId := AThreadId; + if AMetadata <> nil then + FMetadata := AMetadata + else + FMetadata := TDictionary.Create; +end; + +destructor TAgentState.Destroy; +begin + FMetadata.Free; + inherited; +end; + +function TAgentState.CloneMetadata: TDictionary; +var + Pair: TPair; +begin + Result := TDictionary.Create; + if FMetadata = nil then + Exit; + for Pair in FMetadata do + Result.AddOrSetValue(Pair.Key, Pair.Value); +end; + +function TAgentState.CloneMessages: TArray; +begin + Result := Copy(FMessages); +end; + +function TAgentState.ClonePendingCalls: TArray; +begin + Result := Copy(FPendingCalls); +end; + +function TAgentState.WithMessage(const AMsg: TLLMMessage): TAgentState; +var + Msgs: TArray; +begin + Msgs := CloneMessages; + SetLength(Msgs, Length(Msgs) + 1); + Msgs[High(Msgs)] := AMsg; + Result := TAgentState.CreateInternal( + Msgs, ClonePendingCalls, FCurrentNode, FIteration, FIsDone, + FFinalAnswer, CloneMetadata, FThreadId); +end; + +function TAgentState.WithMessages(const AMsgs: TArray): TAgentState; +begin + Result := TAgentState.CreateInternal( + Copy(AMsgs), ClonePendingCalls, FCurrentNode, FIteration, FIsDone, + FFinalAnswer, CloneMetadata, FThreadId); +end; + +function TAgentState.WithPendingCalls(const ACalls: TArray): TAgentState; +begin + Result := TAgentState.CreateInternal( + CloneMessages, Copy(ACalls), FCurrentNode, FIteration, FIsDone, + FFinalAnswer, CloneMetadata, FThreadId); +end; + +function TAgentState.ClearPendingCalls: TAgentState; +begin + Result := TAgentState.CreateInternal( + CloneMessages, nil, FCurrentNode, FIteration, FIsDone, + FFinalAnswer, CloneMetadata, FThreadId); +end; + +function TAgentState.WithCurrentNode(const ANode: string): TAgentState; +begin + Result := TAgentState.CreateInternal( + CloneMessages, ClonePendingCalls, ANode, FIteration, FIsDone, + FFinalAnswer, CloneMetadata, FThreadId); +end; + +function TAgentState.WithIteration(AIteration: Integer): TAgentState; +begin + Result := TAgentState.CreateInternal( + CloneMessages, ClonePendingCalls, FCurrentNode, AIteration, FIsDone, + FFinalAnswer, CloneMetadata, FThreadId); +end; + +function TAgentState.NextIteration: TAgentState; +begin + Result := WithIteration(FIteration + 1); +end; + +function TAgentState.AsDone(const AAnswer: string): TAgentState; +begin + Result := TAgentState.CreateInternal( + CloneMessages, ClonePendingCalls, FCurrentNode, FIteration, True, + AAnswer, CloneMetadata, FThreadId); +end; + +function TAgentState.WithMeta(const AKey, AValue: string): TAgentState; +var + Meta: TDictionary; +begin + Meta := CloneMetadata; + Meta.AddOrSetValue(AKey, AValue); + Result := TAgentState.CreateInternal( + CloneMessages, ClonePendingCalls, FCurrentNode, FIteration, FIsDone, + FFinalAnswer, Meta, FThreadId); +end; + +function TAgentState.RestartAt(const ANode: string): TAgentState; +begin + Result := TAgentState.CreateInternal( + CloneMessages, nil, ANode, 0, False, '', CloneMetadata, FThreadId); +end; + +function TAgentState.ClearDone: TAgentState; +begin + Result := TAgentState.CreateInternal( + CloneMessages, ClonePendingCalls, FCurrentNode, FIteration, False, + FFinalAnswer, CloneMetadata, FThreadId); +end; + +function TAgentState.HasPendingCalls: Boolean; +begin + Result := Length(FPendingCalls) > 0; +end; + +function TAgentState.LastMessage: TLLMMessage; +begin + if Length(FMessages) = 0 then + Result := Default(TLLMMessage) + else + Result := FMessages[High(FMessages)]; +end; + +function TAgentState.GetMeta(const AKey: string; const ADefault: string): string; +begin + if (FMetadata = nil) or not FMetadata.TryGetValue(AKey, Result) then + Result := ADefault; +end; + +function TAgentState.ToJson: string; +var + Root: TJSONObject; + JMsgs, JCalls: TJSONArray; + JMeta: TJSONObject; + Msg: TLLMMessage; + TC: TLLMToolCall; + Pair: TPair; +begin + Root := TJSONObject.Create; + try + Root.AddPair('threadId', FThreadId); + Root.AddPair('currentNode', FCurrentNode); + Root.AddPair('iteration', TJSONNumber.Create(FIteration)); + Root.AddPair('isDone', TJSONBool.Create(FIsDone)); + Root.AddPair('finalAnswer', FFinalAnswer); + + JMsgs := TJSONArray.Create; + for Msg in FMessages do + JMsgs.Add(MessageToJson(Msg)); + Root.AddPair('messages', JMsgs); + + JCalls := TJSONArray.Create; + for TC in FPendingCalls do + JCalls.Add(ToolCallToJson(TC)); + Root.AddPair('pendingCalls', JCalls); + + JMeta := TJSONObject.Create; + if FMetadata <> nil then + for Pair in FMetadata do + JMeta.AddPair(Pair.Key, Pair.Value); + Root.AddPair('metadata', JMeta); + + Result := Root.ToJSON; + finally + Root.Free; + end; +end; + +class function TAgentState.FromJson(const AJson: string): TAgentState; +var + Root: TJSONObject; + JMsgs, JCalls: TJSONArray; + JMeta: TJSONObject; + JVal: TJSONValue; + Msgs: TArray; + Calls: TArray; + Meta: TDictionary; + I: Integer; + Pair: TJSONPair; +begin + Root := TJSONObject.ParseJSONValue(AJson) as TJSONObject; + if Root = nil then + raise EArgumentException.Create('JSON de estado inválido'); + try + JMsgs := Root.GetValue('messages') as TJSONArray; + if JMsgs <> nil then + begin + SetLength(Msgs, JMsgs.Count); + for I := 0 to JMsgs.Count - 1 do + Msgs[I] := JsonToMessage(JMsgs.Items[I] as TJSONObject); + end; + + JCalls := Root.GetValue('pendingCalls') as TJSONArray; + if JCalls <> nil then + begin + SetLength(Calls, JCalls.Count); + for I := 0 to JCalls.Count - 1 do + Calls[I] := JsonToToolCall(JCalls.Items[I] as TJSONObject); + end; + + Meta := TDictionary.Create; + JMeta := Root.GetValue('metadata') as TJSONObject; + if JMeta <> nil then + for Pair in JMeta do + begin + JVal := Pair.JsonValue; + if JVal <> nil then + Meta.AddOrSetValue(Pair.JsonString.Value, JVal.Value); + end; + + Result := TAgentState.CreateInternal( + Msgs, + Calls, + Root.GetValue('currentNode', ''), + Root.GetValue('iteration', 0), + Root.GetValue('isDone', False), + Root.GetValue('finalAnswer', ''), + Meta, + Root.GetValue('threadId', '') + ); + finally + Root.Free; + end; +end; + +end. diff --git a/Sources/AI/Graph/Nodes/Dext.AI.Graph.Node.LLM.pas b/Sources/AI/Graph/Nodes/Dext.AI.Graph.Node.LLM.pas new file mode 100644 index 00000000..8e9c6c7f --- /dev/null +++ b/Sources/AI/Graph/Nodes/Dext.AI.Graph.Node.LLM.pas @@ -0,0 +1,123 @@ +{***************************************************************************} +{ } +{ Dext Framework } +{ } +{ Dext.AI.Graph - Orquestração de agentes estilo LangGraph } +{ } +{***************************************************************************} +{ } +{ Description: } +{ TLLMNode — nó padrão que chama o provider LLM (call_model). } +{ } +{***************************************************************************} +unit Dext.AI.Graph.Node.LLM; + +interface + +uses + Dext.AI.Graph.Contracts, + Dext.AI.Graph.State, + Dext.AI.Graph.Graph, + Dext.AI.Agent.Contracts, + System.SysUtils; + +type + TLLMNode = class + private + FToolSchemas: TArray; + public + constructor Create(const AToolSchemas: TArray); + function GetAsHandler: TNodeHandler; + function Execute( + const AState: TAgentState; + const ACtx: TNodeContext + ): TAgentState; + + property AsHandler: TNodeHandler read GetAsHandler; + end; + +function DefaultShouldContinue(const AState: TAgentState): string; + +implementation + +function StopReasonToString(AReason: TLLMStopReason): string; +begin + case AReason of + srEndTurn: Result := 'srEndTurn'; + srToolUse: Result := 'srToolUse'; + srMaxTokens: Result := 'srMaxTokens'; + srError: Result := 'srError'; + else + Result := 'unknown'; + end; +end; + +{ TLLMNode } + +constructor TLLMNode.Create(const AToolSchemas: TArray); +begin + inherited Create; + FToolSchemas := Copy(AToolSchemas); +end; + +function TLLMNode.GetAsHandler: TNodeHandler; +begin + Result := + function(const AState: TAgentState; const ACtx: TNodeContext): TAgentState + begin + Result := Self.Execute(AState, ACtx); + end; +end; + +function TLLMNode.Execute( + const AState: TAgentState; + const ACtx: TNodeContext +): TAgentState; +var + Response: TLLMResponse; + AssistantMsg: TLLMMessage; + Intermediate: TAgentState; +begin + if ACtx.Provider = nil then + raise EGraphExecutionError.Create('Provider LLM ausente no contexto do nó'); + + Response := ACtx.Provider.Complete(AState.Messages, FToolSchemas); + + if Assigned(ACtx.Observer) then + ACtx.Observer.OnLLMResponse(Response.Content, Response.StopReason); + + case Response.StopReason of + srEndTurn: + begin + AssistantMsg := TLLMMessage.Assistant(Response.Content); + Intermediate := AState.WithMessage(AssistantMsg); + try + Result := Intermediate.AsDone(Response.Content); + finally + Intermediate.Free; + end; + end; + srToolUse: + begin + AssistantMsg := TLLMMessage.Assistant(Response.Content, Response.ToolCalls); + Intermediate := AState.WithMessage(AssistantMsg); + try + Result := Intermediate.WithPendingCalls(Response.ToolCalls); + finally + Intermediate.Free; + end; + end; + else + Result := AState.AsDone('[Error: ' + StopReasonToString(Response.StopReason) + ']'); + end; +end; + +function DefaultShouldContinue(const AState: TAgentState): string; +begin + if AState.HasPendingCalls then + Result := 'execute_tools' + else + Result := GRAPH_END; +end; + +end. diff --git a/Sources/AI/Graph/Nodes/Dext.AI.Graph.Node.Tools.pas b/Sources/AI/Graph/Nodes/Dext.AI.Graph.Node.Tools.pas new file mode 100644 index 00000000..92ebd347 --- /dev/null +++ b/Sources/AI/Graph/Nodes/Dext.AI.Graph.Node.Tools.pas @@ -0,0 +1,279 @@ +{***************************************************************************} +{ } +{ Dext Framework } +{ } +{ Dext.AI.Graph - Orquestração de agentes estilo LangGraph } +{ } +{***************************************************************************} +{ } +{ Description: } +{ TToolsNode — nó padrão que executa as tool calls pendentes (ToolNode). } +{ } +{***************************************************************************} +unit Dext.AI.Graph.Node.Tools; + +interface + +uses + Dext.AI.Graph.Contracts, + Dext.AI.Graph.State, + Dext.AI.Graph.Graph, + Dext.AI.Agent.Contracts, + Dext.AI.MCP.Tools, + Dext.AI.MCP.Attributes, + Dext.AI.MCP.Types, + Dext.AI.MCP.Protocol, + System.Rtti, + System.JSON, + System.SysUtils, + System.Generics.Collections; + +type + TToolsNode = class + private + FProviders: TObjectList; + + function ExecuteSingleTool( + const AToolName, AArgsJson: string + ): string; + + function BuildInputSchema(AMethod: TRttiMethod): string; + function BuildToolSchemas: TArray; + function ToolResultToText(const AResult: TMCPToolResult): string; + public + constructor Create; + destructor Destroy; override; + + procedure RegisterProvider(AProvider: TMCPToolProvider); + function GetToolSchemas: TArray; + function GetAsHandler: TNodeHandler; + function Execute( + const AState: TAgentState; + const ACtx: TNodeContext + ): TAgentState; + + property AsHandler: TNodeHandler read GetAsHandler; + end; + +implementation + +uses + System.Classes; + +{ TToolsNode } + +constructor TToolsNode.Create; +begin + inherited Create; + FProviders := TObjectList.Create(True); +end; + +destructor TToolsNode.Destroy; +begin + FProviders.Free; + inherited; +end; + +procedure TToolsNode.RegisterProvider(AProvider: TMCPToolProvider); +begin + if AProvider <> nil then + FProviders.Add(AProvider); +end; + +function TToolsNode.GetAsHandler: TNodeHandler; +begin + Result := + function(const AState: TAgentState; const ACtx: TNodeContext): TAgentState + begin + Result := Self.Execute(AState, ACtx); + end; +end; + +function TToolsNode.GetToolSchemas: TArray; +begin + Result := BuildToolSchemas; +end; + +function TToolsNode.BuildInputSchema(AMethod: TRttiMethod): string; +var + JSchema, JProps, JParam: TJSONObject; + JRequired: TJSONArray; + Attr: TCustomAttribute; + ParamAttr: MCPParamAttribute; +begin + JProps := TJSONObject.Create; + JRequired := TJSONArray.Create; + + for Attr in AMethod.GetAttributes do + if Attr is MCPParamAttribute then + begin + ParamAttr := MCPParamAttribute(Attr); + + JParam := TJSONObject.Create; + JParam.AddPair('description', ParamAttr.Description); + case ParamAttr.ParamType of + ptString: JParam.AddPair('type', 'string'); + ptInteger: JParam.AddPair('type', 'integer'); + ptNumber: JParam.AddPair('type', 'number'); + ptBoolean: JParam.AddPair('type', 'boolean'); + end; + JProps.AddPair(ParamAttr.Name, JParam); + + if ParamAttr.Required then + JRequired.Add(ParamAttr.Name); + end; + + JSchema := TJSONObject.Create; + try + JSchema.AddPair('type', 'object'); + JSchema.AddPair('properties', JProps); + if JRequired.Count > 0 then + JSchema.AddPair('required', JRequired) + else + JRequired.Free; + Result := JSchema.ToJSON; + finally + JSchema.Free; + end; +end; + +function TToolsNode.BuildToolSchemas: TArray; +var + Ctx: TRttiContext; + Provider: TMCPToolProvider; + Method: TRttiMethod; + ToolAttr: MCPToolAttribute; + Schemas: TList; + Schema: TToolSchema; +begin + Ctx := TRttiContext.Create; + Schemas := TList.Create; + try + for Provider in FProviders do + for Method in Ctx.GetType(Provider.ClassType).GetMethods do + begin + ToolAttr := Method.GetAttribute; + if ToolAttr = nil then + Continue; + + Schema := Default(TToolSchema); + Schema.Name := ToolAttr.Name; + Schema.Description := ToolAttr.Description; + Schema.InputSchema := BuildInputSchema(Method); + Schemas.Add(Schema); + end; + + Result := Schemas.ToArray; + finally + Schemas.Free; + Ctx.Free; + end; +end; + +function TToolsNode.ToolResultToText(const AResult: TMCPToolResult): string; +var + Item: TMCPContent; + Parts: TStringBuilder; +begin + Parts := TStringBuilder.Create; + try + for Item in AResult.Content do + if Item.ContentType = mctText then + begin + if Parts.Length > 0 then + Parts.Append(sLineBreak); + Parts.Append(Item.TextValue); + end; + + Result := Parts.ToString; + if AResult.IsError then + Result := '[Error] ' + Result; + finally + Parts.Free; + end; +end; + +function TToolsNode.ExecuteSingleTool( + const AToolName, AArgsJson: string +): string; +var + Ctx: TRttiContext; + Provider: TMCPToolProvider; + RttiType: TRttiType; + Method: TRttiMethod; + ToolAttr: MCPToolAttribute; + JArgs: TJSONObject; + InvokeResult: TValue; +begin + Ctx := TRttiContext.Create; + try + JArgs := TJSONObject.ParseJSONValue(AArgsJson) as TJSONObject; + if JArgs = nil then + JArgs := TJSONObject.Create; + try + for Provider in FProviders do + begin + RttiType := Ctx.GetType(Provider.ClassType); + for Method in RttiType.GetMethods do + begin + ToolAttr := Method.GetAttribute; + if (ToolAttr = nil) or (ToolAttr.Name <> AToolName) then + Continue; + + try + Provider.BeforeCall(AToolName, JArgs); + InvokeResult := Method.Invoke(Provider, [TValue.From(JArgs)]); + Provider.AfterCall(AToolName); + Exit(ToolResultToText(InvokeResult.AsType)); + except + on E: Exception do + Exit('[Error] ' + E.Message); + end; + end; + end; + + Result := '[Error: Tool not found: ' + AToolName + ']'; + finally + JArgs.Free; + end; + finally + Ctx.Free; + end; +end; + +function TToolsNode.Execute( + const AState: TAgentState; + const ACtx: TNodeContext +): TAgentState; +var + NewState: TAgentState; + TC: TLLMToolCall; + ToolResultText: string; + Old: TAgentState; +begin + NewState := AState; + for TC in AState.PendingCalls do + begin + if Assigned(ACtx.Observer) then + ACtx.Observer.OnToolCall(TC.Name, TC.ArgsJson); + + ToolResultText := ExecuteSingleTool(TC.Name, TC.ArgsJson); + + if Assigned(ACtx.Observer) then + ACtx.Observer.OnToolResult(TC.Name, ToolResultText); + + Old := NewState; + NewState := NewState.WithMessage(TLLMMessage.ToolResult(TC.Id, ToolResultText)); + if (Old <> AState) and (Old <> NewState) then + Old.Free; + end; + + Old := NewState; + NewState := NewState.ClearPendingCalls; + if (Old <> AState) and (Old <> NewState) then + Old.Free; + + Result := NewState; +end; + +end.