Created
April 19, 2026 21:32
-
-
Save quarryman/756433182619cef2a827b282e2bed4b0 to your computer and use it in GitHub Desktop.
agentic flow with Effect.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Agentic Workflow — orchestration layer (or maybe not). | |
| * | |
| * Services (JiraService, GitService, etc.) are defined in ./services.ts. | |
| * This file contains: LLM functions, tools, workflow definition, and composition. | |
| * | |
| * Architecture: | |
| * @effect/workflow (deterministic orchestration) | |
| * │ | |
| * ├─ Activity: Fetch Jira ← JiraService | |
| * ├─ Activity: Analyze Requirements ← LLM (no loop) | |
| * ├─ Activity: Create Branch ← GitService | |
| * ├─ Activity: Implement Fix ← AgenticLoopService (LLM agentic loop) | |
| * ├─ GATE: Run Tests ← TestRunnerService | |
| * ├─ GATE: Open Browser ← BrowserService | |
| * ├─ Activity: Visual Verification ← AgenticLoopService (LLM agentic loop) | |
| * ├─ DurableDeferred: User Approval ← blocks until human approves | |
| * ├─ Activity: Create PR ← GitService | |
| * └─ Activity: Update Jira ← JiraService | |
| */ | |
| import { Workflow, Activity, DurableDeferred } from '@effect/workflow'; | |
| import { Completions, LanguageModel, Tool, Toolkit } from '@effect/ai'; | |
| import { AnthropicCompletions } from '@effect/ai-anthropic'; | |
| import { Effect, Layer, Schema } from 'effect'; | |
| import { | |
| JiraService, | |
| JiraServiceLive, | |
| GitService, | |
| GitServiceLive, | |
| TestRunnerService, | |
| TestRunnerServiceLive, | |
| BrowserService, | |
| BrowserServiceLive, | |
| } from './services'; | |
| // --------------------------------------------------------------------------- | |
| // LLM tools — shared by ImplementFixService and VisualVerificationService | |
| // --------------------------------------------------------------------------- | |
| const ReadFile = Tool.make('ReadFile', { | |
| description: 'Read a file from the project', | |
| parameters: { path: Schema.String }, | |
| success: Schema.String, | |
| }); | |
| const WriteFile = Tool.make('WriteFile', { | |
| description: 'Write content to a file', | |
| parameters: { path: Schema.String, content: Schema.String }, | |
| success: Schema.String, | |
| }); | |
| const RunCommand = Tool.make('RunCommand', { | |
| description: 'Run a shell command and return stdout', | |
| parameters: { command: Schema.String }, | |
| success: Schema.String, | |
| }); | |
| const AgentTools = Toolkit.make(ReadFile, WriteFile, RunCommand); | |
| const AgentToolHandlers = AgentTools.toLayer({ | |
| ReadFile: ({ path }) => | |
| Effect.try(() => { | |
| const fs = require('fs'); | |
| return fs.readFileSync(path, 'utf-8'); | |
| }), | |
| WriteFile: ({ path, content }) => | |
| Effect.try(() => { | |
| const fs = require('fs'); | |
| fs.writeFileSync(path, content); | |
| return `Written to ${path}`; | |
| }), | |
| RunCommand: ({ command }) => | |
| Effect.try(() => { | |
| const { execSync } = require('child_process'); | |
| return execSync(command, { encoding: 'utf-8', timeout: 30_000 }); | |
| }), | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // LLM services — Effect.Service with default implementations | |
| // --------------------------------------------------------------------------- | |
| /** | |
| * AnalysisService — single LLM call to analyze a Jira ticket. | |
| * yield* AnalysisService to see the default implementation inline. | |
| */ | |
| class AnalysisService extends Effect.Service<AnalysisService>()('AnalysisService', { | |
| succeed: { | |
| analyze: (ticket: any) => | |
| Effect.gen(function* () { | |
| const completions = yield* Completions.Completions; | |
| const response = yield* completions.generateText({ | |
| prompt: `Analyze this Jira ticket and extract: | |
| 1. Is this a bug or feature? | |
| 2. What files likely need changes? | |
| 3. What are the acceptance criteria? | |
| Ticket: ${ticket.fields?.summary} | |
| Description: ${ticket.fields?.description}`, | |
| }); | |
| return response.text; | |
| }), | |
| }, | |
| }) {} | |
| /** | |
| * AgenticLoopService — LLM agentic loop. | |
| * Gets tools (ReadFile, WriteFile, RunCommand) and loops: | |
| * read files → edit code → run tests → read errors → edit again → done | |
| */ | |
| class AgenticLoopService extends Effect.Service<AgenticLoopService>()('AgenticLoopService', { | |
| succeed: { | |
| fix: (analysis: string) => | |
| LanguageModel.generateText({ | |
| prompt: `Based on this analysis, implement the fix. | |
| Read the relevant files, make changes, and verify by running tests. | |
| Analysis: | |
| ${analysis}`, | |
| tools: AgentTools, | |
| maxSteps: 50, | |
| }).pipe( | |
| Effect.provide(AgentToolHandlers), | |
| Effect.map((response) => response.text), | |
| ), | |
| verify: (ticketDescription: string) => | |
| LanguageModel.generateText({ | |
| // check how do i force it to simply use predefined playwright-cli skill | |
| prompt: `You are verifying a bug fix in the browser. The browser is already open and authenticated. | |
| ## Your task | |
| Confirm that the following issue is fixed by interacting with the app: | |
| ${ticketDescription} | |
| ## Available commands (use RunCommand tool) | |
| - playwright-cli snapshot — see current page state as YAML with element refs | |
| - playwright-cli click <ref> — click an element (e.g. "playwright-cli click e40") | |
| - playwright-cli fill <ref> "text" — fill an input field | |
| - playwright-cli goto <url> — navigate to a URL | |
| - playwright-cli select <ref> "value" — select dropdown option | |
| ## Workflow | |
| 1. Run "playwright-cli snapshot" to see the current page | |
| 2. Navigate to the relevant part of the app | |
| 3. Reproduce the scenario described in the ticket | |
| 4. Check that the fix is working correctly | |
| 5. Return PASS with explanation if fix is confirmed, or FAIL with what's wrong | |
| Always start with a snapshot to understand the current page state.`, | |
| tools: AgentTools, | |
| maxSteps: 30, | |
| }).pipe( | |
| Effect.provide(AgentToolHandlers), | |
| Effect.map((response) => response.text), | |
| ), | |
| }, | |
| }) {} | |
| // --------------------------------------------------------------------------- | |
| // Workflow schema | |
| // --------------------------------------------------------------------------- | |
| const TaskWorkflow = Workflow.make({ | |
| name: 'JiraTaskWorkflow', | |
| payload: Schema.Struct({ jiraId: Schema.String }), | |
| success: Schema.Struct({ prUrl: Schema.String }), | |
| error: Schema.Any, | |
| idempotencyKey: ({ jiraId }) => jiraId, | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // Workflow implementation — depends on services, not implementations | |
| // --------------------------------------------------------------------------- | |
| const TaskWorkflowLayer = TaskWorkflow.toLayer( | |
| Effect.fn(function* (payload) { | |
| const jira = yield* JiraService; | |
| const git = yield* GitService; | |
| const tests = yield* TestRunnerService; | |
| const browser = yield* BrowserService; | |
| const noLoopPrompt = yield* AnalysisService; | |
| const agenticLoop = yield* AgenticLoopService; | |
| // [Deterministic] Step 1: Fetch Jira ticket (deterministic — JiraService) | |
| const ticket = yield* Activity.make({ | |
| name: 'FetchJiraTicket', | |
| execute: jira.getTicket(payload.jiraId), | |
| }); | |
| // [LLM judgment] Step 2: Analyze requirements | |
| const analysis = yield* Activity.make({ | |
| name: 'AnalyzeRequirements', | |
| execute: noLoopPrompt.analyze(ticket), | |
| }); | |
| // [Deterministic] Step 3: Create branch (deterministic — GitService) | |
| yield* Activity.make({ | |
| name: 'CreateBranch', | |
| execute: git.createBranch(`${payload.jiraId}-fix`), | |
| }); | |
| // [LLM agentic loop] Step 4: Implement fix (LLM agentic loop — ImplementFixService) | |
| yield* Activity.make({ | |
| name: 'ImplementFix', | |
| execute: agenticLoop.fix(analysis), | |
| }); | |
| // [Deterministic] Step 5: GATE — tests MUST pass or workflow fails (TestRunnerService) | |
| yield* Activity.make({ | |
| name: 'RunTests', | |
| execute: tests.runAll(), | |
| }); | |
| // [Deterministic] Step 6: GATE — browser MUST open, retry twice (BrowserService) | |
| yield* Activity.make({ | |
| name: 'OpenBrowser', | |
| execute: browser.openAuthenticated(), | |
| }).pipe(Activity.retry({ times: 2 })); | |
| // [LLM agentic loop] Step 7: Visual verification — LLM agentic loop using playwright-cli | |
| // via RunCommand. Navigates the app, reproduces the scenario, confirms fix. | |
| yield* Activity.make({ | |
| name: 'VisualVerification', | |
| execute: agenticLoop.verify(ticket.fields?.description ?? ''), | |
| }); | |
| // [Human-in-the-loop] Step 8: BLOCKS until user approves (stdin?) | |
| const ApprovalSignal = DurableDeferred.make('UserApproval'); | |
| yield* DurableDeferred.await(ApprovalSignal); | |
| // [Deterministic] Step 9: Create PR (deterministic — GitService) | |
| const prUrl = yield* Activity.make({ | |
| name: 'CreatePR', | |
| execute: git.createPR({ | |
| title: `${payload.jiraId}: Fix`, | |
| body: `Automated fix for ${payload.jiraId}`, | |
| }), | |
| }); | |
| // [Deterministic] Step 10: Update Jira (deterministic — JiraService) | |
| yield* Activity.make({ | |
| name: 'UpdateJira', | |
| execute: jira.addComment(payload.jiraId, `PR created: ${prUrl}`), | |
| }); | |
| // [Deterministic] Step 11: Close browser (cleanup — BrowserService) | |
| yield* browser.close(); | |
| return { prUrl }; | |
| }), | |
| ); | |
| // --------------------------------------------------------------------------- | |
| // Provider layer — swap model by changing one line | |
| // --------------------------------------------------------------------------- | |
| const AiLayer = AnthropicCompletions.layer({ | |
| model: 'claude-sonnet-4-20250514', | |
| apiKey: process.env.ANTHROPIC_API_KEY!, | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // Compose all layers | |
| // | |
| // For testing, swap any *Live layer with a mock: | |
| // const JiraServiceTest = Layer.succeed(JiraService, { ... }) | |
| // --------------------------------------------------------------------------- | |
| const LiveLayer = Layer.mergeAll( | |
| JiraServiceLive, | |
| GitServiceLive, | |
| TestRunnerServiceLive, | |
| BrowserServiceLive, | |
| AnalysisService.Default, | |
| AgenticLoopService.Default, | |
| AiLayer, | |
| ); | |
| const MainLayer = Layer.provide(TaskWorkflowLayer, LiveLayer); | |
| // --------------------------------------------------------------------------- | |
| // Run | |
| // --------------------------------------------------------------------------- | |
| const program = Effect.gen(function* () { | |
| const workflow = yield* TaskWorkflow; | |
| const result = yield* workflow.execute({ jiraId: 'DA-18300' }); | |
| Effect.log(`PR created: ${result.prUrl}`); | |
| }); | |
| Effect.runPromise(Effect.provide(program, MainLayer)).catch(Effect.error); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment