agentf 0.4.1 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 289cd869806a950c6bb25a92b56cc05d5f90481f3bbe61af65d97af23768d321
4
- data.tar.gz: 8f16969b26684677f01d01821c541d14a338b924a310ad9cdd83435e7b7896b3
3
+ metadata.gz: 5ba903d323be3e277a84ea8320af5b84a93456ad44b5d5aca0eb05e4c4ba29ae
4
+ data.tar.gz: 9a7125925e3a3fbf85b382b5ea278e3b39390c618dc9a571c1657acef86ec82d
5
5
  SHA512:
6
- metadata.gz: 230df28afb68ed450b76c16185dc14227342cfc3048ed5ab25183a99aebb57c79833180d6185c3d35d33e89dac9d9a514b8ee6ebbeef8ecf64d3822f34db0b0f
7
- data.tar.gz: 9347250f29a7402d17ed0784c16387e268461785c02aa1d3f94024bdab6a89f2022a065169b1055f6b168d4a0472a193000e88f5c428ea8ce4757ed2843ff066
6
+ metadata.gz: 7c5703863b0d31aa5318c124d690eb0dccb93839935e07155a6bd6265267741183c38475761d597b15bfa8f18ea3b7a515b7613c7d31a9327eb0e6162a186d09
7
+ data.tar.gz: d5b4072ee13a2e40d07f93c25c147298d63e8e782bfb75a66a03b10b90c4c8b844da396271fa0ed00f695be42067c009934ef3b4f8f5a01ad86d8272807abaf6
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentf
4
+ class AgentContractViolation < Agentf::Error
5
+ attr_reader :agent_name, :violations
6
+
7
+ def initialize(agent_name:, violations:)
8
+ @agent_name = agent_name
9
+ @violations = violations
10
+ super("Agent contract violated for #{agent_name}: #{violations.map { |v| v['code'] }.join(', ')}")
11
+ end
12
+ end
13
+
14
+ class AgentExecutionContract
15
+ def initialize(enabled:, mode:, policy: Agentf::AgentPolicy.new)
16
+ @enabled = enabled
17
+ @mode = normalize_mode(mode)
18
+ @policy = policy
19
+ end
20
+
21
+ def enabled?
22
+ @enabled && @mode != "off"
23
+ end
24
+
25
+ def enforcing?
26
+ enabled? && @mode == "enforcing"
27
+ end
28
+
29
+ def before!(agent_name:, boundaries:, context: {})
30
+ violations = @policy.validate(
31
+ agent_name: agent_name,
32
+ boundaries: boundaries,
33
+ context: context,
34
+ result: nil,
35
+ phase: :before
36
+ )
37
+ handle!(agent_name: agent_name, violations: violations)
38
+ end
39
+
40
+ def after!(agent_name:, boundaries:, context: {}, result:)
41
+ violations = @policy.validate(
42
+ agent_name: agent_name,
43
+ boundaries: boundaries,
44
+ context: context,
45
+ result: result,
46
+ phase: :after
47
+ ) + coding_execution_violations(agent_name: agent_name, result: result) + tdd_violations(agent_name: agent_name, context: context)
48
+ handle!(agent_name: agent_name, violations: violations)
49
+ end
50
+
51
+ private
52
+
53
+ def normalize_mode(value)
54
+ mode = value.to_s.strip.downcase
55
+ return mode if %w[advisory enforcing off].include?(mode)
56
+
57
+ "enforcing"
58
+ end
59
+
60
+ def handle!(agent_name:, violations:)
61
+ return if violations.empty? || !enabled?
62
+
63
+ raise Agentf::AgentContractViolation.new(agent_name: agent_name, violations: violations) if enforcing?
64
+ end
65
+
66
+ def tdd_violations(agent_name:, context:)
67
+ return [] unless [Agentf::AgentRoles::ENGINEER, Agentf::AgentRoles::UI_ENGINEER].include?(agent_name)
68
+
69
+ tdd_required = context["tdd_required"] == true || context.key?("tdd_phase")
70
+ return [] unless tdd_required
71
+
72
+ phase = context["tdd_phase"].to_s.strip
73
+ violations = []
74
+ if phase.empty?
75
+ violations << {
76
+ "code" => "missing_tdd_phase",
77
+ "severity" => "error",
78
+ "message" => "#{agent_name} requires explicit tdd_phase when TDD is enabled",
79
+ "agent" => agent_name,
80
+ "type" => "agent_contract"
81
+ }
82
+ end
83
+
84
+ if phase == "green" && context["expected_test_fix"].to_s.strip.empty?
85
+ violations << {
86
+ "code" => "missing_expected_test_fix",
87
+ "severity" => "error",
88
+ "message" => "#{agent_name} green phase requires expected_test_fix from failing test",
89
+ "agent" => agent_name,
90
+ "type" => "agent_contract"
91
+ }
92
+ end
93
+
94
+ violations
95
+ end
96
+
97
+ def coding_execution_violations(agent_name:, result:)
98
+ coding_agents = [Agentf::AgentRoles::ENGINEER, Agentf::AgentRoles::UI_ENGINEER, Agentf::AgentRoles::INCIDENT_RESPONDER]
99
+ return [] unless coding_agents.include?(agent_name)
100
+
101
+ unless result.is_a?(Hash) && [true, false].include?(result["success"])
102
+ return [
103
+ {
104
+ "code" => "invalid_success_flag",
105
+ "severity" => "error",
106
+ "message" => "#{agent_name} must return boolean success",
107
+ "agent" => agent_name,
108
+ "type" => "agent_contract"
109
+ }
110
+ ]
111
+ end
112
+
113
+ []
114
+ end
115
+ end
116
+ end
@@ -4,19 +4,34 @@ module Agentf
4
4
  class AgentPolicy
5
5
  REQUIRED_KEYS = %w[always ask_first never].freeze
6
6
 
7
- def validate(agent_name:, boundaries:, context: {}, result: nil)
7
+ def validate(agent_name:, boundaries:, context: {}, result: nil, phase: :any)
8
8
  errors = []
9
9
  boundaries = normalize(boundaries)
10
10
 
11
- required_inputs = Array(boundaries["required_inputs"])
12
- missing_inputs = required_inputs.reject { |key| context.key?(key) }
13
- unless missing_inputs.empty?
14
- errors << violation(
15
- code: "missing_required_inputs",
16
- severity: "error",
17
- message: "#{agent_name} missing required inputs: #{missing_inputs.join(', ')}",
18
- agent: agent_name
19
- )
11
+ if %i[any before].include?(phase)
12
+ required_inputs = Array(boundaries["required_inputs"])
13
+ missing_inputs = required_inputs.reject { |key| context.key?(key) }
14
+ unless missing_inputs.empty?
15
+ errors << violation(
16
+ code: "missing_required_inputs",
17
+ severity: "error",
18
+ message: "#{agent_name} missing required inputs: #{missing_inputs.join(', ')}",
19
+ agent: agent_name
20
+ )
21
+ end
22
+ end
23
+
24
+ if %i[any after].include?(phase)
25
+ required_outputs = Array(boundaries["required_outputs"])
26
+ missing_outputs = required_outputs.reject { |key| output_present?(result, key) }
27
+ unless missing_outputs.empty?
28
+ errors << violation(
29
+ code: "missing_required_outputs",
30
+ severity: "error",
31
+ message: "#{agent_name} missing required outputs: #{missing_outputs.join(', ')}",
32
+ agent: agent_name
33
+ )
34
+ end
20
35
  end
21
36
 
22
37
  if result.is_a?(Hash) && result["error"]
@@ -41,6 +56,14 @@ module Agentf
41
56
 
42
57
  private
43
58
 
59
+ def output_present?(result, key)
60
+ return false unless result.is_a?(Hash)
61
+ return false unless result.key?(key)
62
+
63
+ value = result[key]
64
+ !value.nil?
65
+ end
66
+
44
67
  def violation(code:, severity:, message:, agent:)
45
68
  {
46
69
  "code" => code,
@@ -55,11 +55,36 @@ module Agentf
55
55
  def initialize(memory)
56
56
  @memory = memory
57
57
  @name = self.class.typed_name
58
+ @execution_contract = Agentf::AgentExecutionContract.new(
59
+ enabled: Agentf.config.agent_contract_enabled,
60
+ mode: Agentf.config.agent_contract_mode
61
+ )
58
62
  end
59
63
 
60
64
  def log(message)
61
65
  puts "\n[#{@name}] #{message}"
62
66
  end
67
+
68
+ private
69
+
70
+ def execute_with_contract(context: {})
71
+ @execution_contract.before!(
72
+ agent_name: name,
73
+ boundaries: self.class.policy_boundaries,
74
+ context: context
75
+ )
76
+
77
+ result = yield
78
+
79
+ @execution_contract.after!(
80
+ agent_name: name,
81
+ boundaries: self.class.policy_boundaries,
82
+ context: context,
83
+ result: result
84
+ )
85
+
86
+ result
87
+ end
63
88
  end
64
89
  end
65
90
  end
@@ -48,8 +48,8 @@ module Agentf
48
48
  "always" => ["Return analysis with root causes and suggested fix", "Persist debugging lesson"],
49
49
  "ask_first" => ["Applying speculative fixes without reproducible error"],
50
50
  "never" => ["Discard stack trace context when available"],
51
- "required_inputs" => [],
52
- "required_outputs" => ["analysis"]
51
+ "required_inputs" => ["error_text"],
52
+ "required_outputs" => ["analysis", "success"]
53
53
  }
54
54
  end
55
55
 
@@ -59,32 +59,35 @@ module Agentf
59
59
  end
60
60
 
61
61
  def diagnose(error, context: nil)
62
- log "Diagnosing error"
63
- log " Error: #{error[0..100]}..."
64
-
65
- analysis = @commands.parse_error(error)
66
-
67
- memory.store_episode(
68
- type: "lesson",
69
- title: "Debugged: #{error[0..50]}...",
70
- description: "Root cause: #{analysis.possible_causes.first}. Fix: #{analysis.suggested_fix}",
71
- context: context.to_s,
72
- tags: ["debugging", "error", "fix"],
73
- agent: name
74
- )
75
-
76
- log "Root cause: #{analysis.possible_causes.first}"
77
- log "Suggested fix: #{analysis.suggested_fix}"
78
-
79
- {
80
- "error" => error,
81
- "analysis" => {
82
- "error_type" => analysis.error_type,
83
- "possible_causes" => analysis.possible_causes,
84
- "suggested_fix" => analysis.suggested_fix,
85
- "stack_trace" => analysis.stack_trace
62
+ payload = { "error_text" => error, "context" => context }
63
+ execute_with_contract(context: payload) do
64
+ log "Diagnosing error"
65
+ log " Error: #{error[0..100]}..."
66
+
67
+ analysis = @commands.parse_error(error)
68
+
69
+ memory.store_episode(
70
+ type: "lesson",
71
+ title: "Debugged: #{error[0..50]}...",
72
+ description: "Root cause: #{analysis.possible_causes.first}. Fix: #{analysis.suggested_fix}",
73
+ context: context.to_s,
74
+ tags: ["debugging", "error", "fix"],
75
+ agent: name
76
+ )
77
+
78
+ log "Root cause: #{analysis.possible_causes.first}"
79
+ log "Suggested fix: #{analysis.suggested_fix}"
80
+
81
+ {
82
+ "success" => true,
83
+ "analysis" => {
84
+ "error_type" => analysis.error_type,
85
+ "possible_causes" => analysis.possible_causes,
86
+ "suggested_fix" => analysis.suggested_fix,
87
+ "stack_trace" => analysis.stack_trace
88
+ }
86
89
  }
87
- }
90
+ end
88
91
  end
89
92
  end
90
93
  end
@@ -48,8 +48,8 @@ module Agentf
48
48
  "always" => ["Return generated component details", "Persist successful implementation pattern"],
49
49
  "ask_first" => ["Changing primary UI framework"],
50
50
  "never" => ["Return empty generated code for successful design task"],
51
- "required_inputs" => [],
52
- "required_outputs" => ["component", "generated_code"]
51
+ "required_inputs" => ["design_spec"],
52
+ "required_outputs" => ["component", "generated_code", "success"]
53
53
  }
54
54
  end
55
55
 
@@ -59,26 +59,29 @@ module Agentf
59
59
  end
60
60
 
61
61
  def implement_design(design_spec, framework: "react")
62
- log "Implementing design: #{design_spec}"
63
-
64
- spec = @commands.generate_component("GeneratedComponent", design_spec)
65
-
66
- memory.store_success(
67
- title: "Implemented design: #{design_spec}",
68
- description: "Created #{spec.name} in #{spec.framework}",
69
- context: "Framework: #{framework}",
70
- tags: ["design", "ui", framework],
71
- agent: name
72
- )
73
-
74
- log "Created component: #{spec.name}"
75
-
76
- {
77
- "design_spec" => design_spec,
78
- "component" => spec.name,
79
- "framework" => framework,
80
- "generated_code" => spec.code
81
- }
62
+ execute_with_contract(context: { "design_spec" => design_spec, "framework" => framework }) do
63
+ log "Implementing design: #{design_spec}"
64
+
65
+ spec = @commands.generate_component("GeneratedComponent", design_spec)
66
+
67
+ memory.store_success(
68
+ title: "Implemented design: #{design_spec}",
69
+ description: "Created #{spec.name} in #{spec.framework}",
70
+ context: "Framework: #{framework}",
71
+ tags: ["design", "ui", framework],
72
+ agent: name
73
+ )
74
+
75
+ log "Created component: #{spec.name}"
76
+
77
+ {
78
+ "design_spec" => design_spec,
79
+ "component" => spec.name,
80
+ "framework" => framework,
81
+ "generated_code" => spec.code,
82
+ "success" => true
83
+ }
84
+ end
82
85
  end
83
86
  end
84
87
  end
@@ -47,33 +47,35 @@ module Agentf
47
47
  "always" => ["Report approval decision", "Highlight known pitfalls in review findings"],
48
48
  "ask_first" => ["Approving with unresolved critical security issues"],
49
49
  "never" => ["Approve without any review evidence"],
50
- "required_inputs" => [],
50
+ "required_inputs" => ["execution"],
51
51
  "required_outputs" => ["approved", "issues"]
52
52
  }
53
53
  end
54
54
 
55
55
  def review(subtask_result)
56
- log "Reviewing subtask #{subtask_result['subtask_id']}"
56
+ execute_with_contract(context: { "execution" => subtask_result }) do
57
+ log "Reviewing subtask #{subtask_result['subtask_id']}"
57
58
 
58
- pitfalls = memory.get_pitfalls(limit: 5)
59
- memories = memory.get_recent_memories(limit: 5)
59
+ pitfalls = memory.get_pitfalls(limit: 5)
60
+ memories = memory.get_recent_memories(limit: 5)
60
61
 
61
- issues = []
62
+ issues = []
62
63
 
63
- pitfalls.each do |pitfall|
64
- issues << "Warning: Known pitfall - #{pitfall['title']}" if pitfall["type"] == "pitfall"
65
- end
64
+ pitfalls.each do |pitfall|
65
+ issues << "Warning: Known pitfall - #{pitfall['title']}" if pitfall["type"] == "pitfall"
66
+ end
66
67
 
67
- approved = issues.empty?
68
+ approved = issues.empty?
68
69
 
69
- if approved
70
- log "Approved (no issues found)"
71
- else
72
- log "Issues found: #{issues.size}"
73
- issues.each { |issue| log " - #{issue}" }
74
- end
70
+ if approved
71
+ log "Approved (no issues found)"
72
+ else
73
+ log "Issues found: #{issues.size}"
74
+ issues.each { |issue| log " - #{issue}" }
75
+ end
75
76
 
76
- { "approved" => approved, "issues" => issues }
77
+ { "approved" => approved, "issues" => issues }
78
+ end
77
79
  end
78
80
  end
79
81
  end
@@ -48,7 +48,7 @@ module Agentf
48
48
  "always" => ["Return issue list and best practices", "Persist outcome as success or pitfall"],
49
49
  "ask_first" => ["Allowing known secret patterns in context"],
50
50
  "never" => ["Echo raw secrets in output"],
51
- "required_inputs" => [],
51
+ "required_inputs" => ["task"],
52
52
  "required_outputs" => ["issues", "best_practices"]
53
53
  }
54
54
  end
@@ -59,30 +59,32 @@ module Agentf
59
59
  end
60
60
 
61
61
  def assess(task:, context: {})
62
- log "Running security assessment"
63
-
64
- findings = @commands.scan(task: task, context: context)
65
- summary = summarize_findings(findings)
66
-
67
- if findings["issues"].empty?
68
- memory.store_success(
69
- title: "Security review passed",
70
- description: summary,
71
- context: task,
72
- tags: ["security", "pass"],
73
- agent: name
74
- )
75
- else
76
- memory.store_pitfall(
77
- title: "Security findings detected",
78
- description: summary,
79
- context: task,
80
- tags: ["security", "warning"],
81
- agent: name
82
- )
62
+ execute_with_contract(context: context.merge("task" => task)) do
63
+ log "Running security assessment"
64
+
65
+ findings = @commands.scan(task: task, context: context)
66
+ summary = summarize_findings(findings)
67
+
68
+ if findings["issues"].empty?
69
+ memory.store_success(
70
+ title: "Security review passed",
71
+ description: summary,
72
+ context: task,
73
+ tags: ["security", "pass"],
74
+ agent: name
75
+ )
76
+ else
77
+ memory.store_pitfall(
78
+ title: "Security findings detected",
79
+ description: summary,
80
+ context: task,
81
+ tags: ["security", "warning"],
82
+ agent: name
83
+ )
84
+ end
85
+
86
+ findings.merge("best_practices" => @commands.best_practices)
83
87
  end
84
-
85
- findings.merge("best_practices" => @commands.best_practices)
86
88
  end
87
89
 
88
90
  private
@@ -47,37 +47,44 @@ module Agentf
47
47
  "always" => ["Persist execution outcome", "Return deterministic success boolean"],
48
48
  "ask_first" => ["Applying architecture style changes across unrelated modules"],
49
49
  "never" => ["Claim implementation complete without execution result"],
50
- "required_inputs" => [],
50
+ "required_inputs" => ["description"],
51
51
  "required_outputs" => ["subtask_id", "success"]
52
52
  }
53
53
  end
54
54
 
55
55
  def execute(subtask)
56
- log "Executing: #{subtask['description']}"
56
+ normalized_subtask = subtask.merge(
57
+ "id" => subtask["id"] || "ad-hoc",
58
+ "description" => subtask["description"] || "Execute implementation step"
59
+ )
57
60
 
58
- success = subtask.fetch("success", true)
61
+ execute_with_contract(context: normalized_subtask) do
62
+ log "Executing: #{normalized_subtask['description']}"
59
63
 
60
- if success
61
- memory.store_success(
62
- title: "Completed: #{subtask['description']}",
63
- description: "Successfully executed subtask #{subtask['id']}",
64
- context: "Working on #{subtask.fetch('task', 'unknown task')}",
65
- tags: ["implementation", subtask.fetch("language", "general")],
66
- agent: name
67
- )
68
- log "Stored success memory"
69
- else
70
- memory.store_pitfall(
71
- title: "Failed: #{subtask['description']}",
72
- description: "Subtask #{subtask['id']} failed",
73
- context: "Working on #{subtask.fetch('task', 'unknown task')}",
74
- tags: ["failure", "implementation"],
75
- agent: name
76
- )
77
- log "Stored pitfall memory"
78
- end
64
+ success = normalized_subtask.fetch("success", true)
65
+
66
+ if success
67
+ memory.store_success(
68
+ title: "Completed: #{normalized_subtask['description']}",
69
+ description: "Successfully executed subtask #{normalized_subtask['id']}",
70
+ context: "Working on #{normalized_subtask.fetch('task', 'unknown task')}",
71
+ tags: ["implementation", normalized_subtask.fetch("language", "general")],
72
+ agent: name
73
+ )
74
+ log "Stored success memory"
75
+ else
76
+ memory.store_pitfall(
77
+ title: "Failed: #{normalized_subtask['description']}",
78
+ description: "Subtask #{normalized_subtask['id']} failed",
79
+ context: "Working on #{normalized_subtask.fetch('task', 'unknown task')}",
80
+ tags: ["failure", "implementation"],
81
+ agent: name
82
+ )
83
+ log "Stored pitfall memory"
84
+ end
79
85
 
80
- { "subtask_id" => subtask["id"], "success" => success, "result" => "Code executed" }
86
+ { "subtask_id" => normalized_subtask["id"], "success" => success, "result" => "Code executed" }
87
+ end
81
88
  end
82
89
  end
83
90
  end
@@ -85,6 +85,8 @@ module Agentf
85
85
  AGENTF_METRICS_ENABLED=true|false Enable/disable workflow metrics capture and CLI
86
86
  AGENTF_WORKFLOW_CONTRACT_ENABLED=true|false Enable/disable workflow contract checks
87
87
  AGENTF_WORKFLOW_CONTRACT_MODE=advisory|enforcing|off Contract behavior mode
88
+ AGENTF_AGENT_CONTRACT_ENABLED=true|false Enable/disable per-agent contract checks
89
+ AGENTF_AGENT_CONTRACT_MODE=advisory|enforcing|off Per-agent contract behavior mode
88
90
  AGENTF_DEFAULT_PACK=generic|rails_standard|rails_37signals|rails_feature_spec
89
91
  AGENTF_GEM_PATH=/path/to/gem Path to agentf gem (for OpenCode plugin binary resolution)
90
92
 
@@ -353,7 +353,7 @@ module Agentf
353
353
  end
354
354
 
355
355
  def render_opencode_plugin
356
- <<~TYPESCRIPT
356
+ <<~'TYPESCRIPT'
357
357
  import { execFile } from "node:child_process";
358
358
  import { promisify } from "node:util";
359
359
  import path from "node:path";
@@ -362,44 +362,127 @@ module Agentf
362
362
 
363
363
  const execFileAsync = promisify(execFile);
364
364
 
365
- async function resolveAgentfBinary(directory: string): Promise<string> {
365
+ type AgentfBinaryResolution = {
366
+ binaryPath: string;
367
+ source: string;
368
+ attempts: string[];
369
+ };
370
+
371
+ type PreflightCache = {
372
+ workspaceRoot: string;
373
+ binaryPath: string;
374
+ };
375
+
376
+ let preflightCache: PreflightCache | null = null;
377
+
378
+ function buildPreflightError(attempts: string[], extraDetails?: string): Error {
379
+ const lines = [
380
+ "Agentf plugin preflight failed: unable to run a compatible agentf binary.",
381
+ "",
382
+ "Resolution attempts:",
383
+ ...attempts.map((attempt) => `- ${attempt}`),
384
+ "",
385
+ "Remediation:",
386
+ "- Set AGENTF_GEM_PATH to your installed agentf gem path (contains bin/agentf).",
387
+ "- Ensure your Ruby version manager shims are on PATH (rbenv/asdf/mise), then retry.",
388
+ "- Verify with: agentf version",
389
+ ];
390
+
391
+ if (extraDetails) {
392
+ lines.push("", "Details:", extraDetails);
393
+ }
394
+
395
+ return new Error(lines.join("\n"));
396
+ }
397
+
398
+ function formatExecFailure(error: unknown): string {
399
+ const failure = error as {
400
+ message?: string;
401
+ stdout?: Buffer | string;
402
+ stderr?: Buffer | string;
403
+ };
404
+
405
+ const stdout = failure.stdout?.toString().trim();
406
+ const stderr = failure.stderr?.toString().trim();
407
+ const message = failure.message?.trim();
408
+ const parts = [
409
+ message ? `message: ${message}` : null,
410
+ stderr ? `stderr: ${stderr}` : null,
411
+ stdout ? `stdout: ${stdout}` : null,
412
+ ].filter(Boolean);
413
+
414
+ return parts.length > 0 ? parts.join("\n") : "No additional process output.";
415
+ }
416
+
417
+ async function resolveAgentfBinary(directory: string): Promise<AgentfBinaryResolution> {
418
+ const attempts: string[] = [];
366
419
  const gemPath = process.env.AGENTF_GEM_PATH;
367
420
  if (gemPath) {
368
421
  const binaryPath = path.join(gemPath, "bin", "agentf");
369
422
  if (fs.existsSync(binaryPath)) {
370
- return binaryPath;
423
+ attempts.push(`AGENTF_GEM_PATH succeeded: ${binaryPath}`);
424
+ return { binaryPath, source: "AGENTF_GEM_PATH", attempts };
371
425
  }
426
+ attempts.push(`AGENTF_GEM_PATH set but missing executable: ${binaryPath}`);
427
+ } else {
428
+ attempts.push("AGENTF_GEM_PATH is not set");
372
429
  }
373
430
 
374
431
  const projectRoot = path.resolve(directory);
375
432
  const projectBinary = path.join(projectRoot, "bin", "agentf");
376
433
  if (fs.existsSync(projectBinary)) {
377
- return projectBinary;
434
+ attempts.push(`Project bin fallback succeeded: ${projectBinary}`);
435
+ return { binaryPath: projectBinary, source: "project-bin", attempts };
378
436
  }
437
+ attempts.push(`Project bin fallback missing: ${projectBinary}`);
379
438
 
380
439
  try {
381
440
  const { stdout } = await execFileAsync("command", ["-v", "agentf"], { shell: true });
382
441
  const whichPath = stdout.toString().trim();
383
442
  if (whichPath && fs.existsSync(whichPath)) {
384
- return whichPath;
443
+ attempts.push(`PATH fallback succeeded: ${whichPath}`);
444
+ return { binaryPath: whichPath, source: "PATH", attempts };
385
445
  }
446
+ attempts.push("PATH fallback returned empty or non-existent path");
386
447
  } catch {
387
- // command -v failed
448
+ attempts.push("PATH fallback failed: command -v agentf did not resolve");
388
449
  }
389
450
 
390
- throw new Error(
391
- "agentf binary not found. Set AGENTF_GEM_PATH environment variable to the path where agentf gem is installed, " +
392
- "or ensure bin/agentf exists in your project root. " +
393
- "Example: AGENTF_GEM_PATH=$(bundle show agentf) opencode run \"your task\""
394
- );
451
+ throw buildPreflightError(attempts);
452
+ }
453
+
454
+ async function ensureAgentfPreflight(directory: string): Promise<string> {
455
+ const workspaceRoot = path.resolve(directory);
456
+ if (preflightCache && preflightCache.workspaceRoot === workspaceRoot) {
457
+ return preflightCache.binaryPath;
458
+ }
459
+
460
+ const resolution = await resolveAgentfBinary(workspaceRoot);
461
+
462
+ try {
463
+ await execFileAsync(resolution.binaryPath, ["version"], {
464
+ cwd: workspaceRoot,
465
+ env: process.env,
466
+ maxBuffer: 1024 * 1024,
467
+ });
468
+ } catch (error) {
469
+ throw buildPreflightError(
470
+ resolution.attempts,
471
+ [`Resolved via ${resolution.source}: ${resolution.binaryPath}`, formatExecFailure(error)].join("\n")
472
+ );
473
+ }
474
+
475
+ preflightCache = { workspaceRoot, binaryPath: resolution.binaryPath };
476
+ return resolution.binaryPath;
395
477
  }
396
478
 
397
479
  async function runAgentfCli(directory: string, subcommand: string, command: string, args: string[]) {
398
- const binaryPath = await resolveAgentfBinary(directory);
399
- const commandArgs = ["exec", "ruby", binaryPath, subcommand, command, ...args, "--json"];
480
+ const workspaceRoot = path.resolve(directory);
481
+ const binaryPath = await ensureAgentfPreflight(workspaceRoot);
482
+ const commandArgs = [subcommand, command, ...args, "--json"];
400
483
 
401
- const { stdout } = await execFileAsync("bundle", commandArgs, {
402
- cwd: path.resolve(directory),
484
+ const { stdout } = await execFileAsync(binaryPath, commandArgs, {
485
+ cwd: workspaceRoot,
403
486
  env: process.env,
404
487
  maxBuffer: 1024 * 1024 * 5,
405
488
  });
@@ -409,6 +492,8 @@ module Agentf
409
492
  }
410
493
 
411
494
  export const agentfPlugin: Plugin = async () => {
495
+ await ensureAgentfPreflight(process.env.PWD || process.cwd());
496
+
412
497
  return {
413
498
  tools: {
414
499
  "agentf-code-glob": tool({
@@ -125,6 +125,8 @@ module Agentf
125
125
  logger&.call("→ #{agent_name} Complete")
126
126
  result
127
127
  rescue StandardError => e
128
+ raise if e.is_a?(Agentf::AgentContractViolation)
129
+
128
130
  logger&.call("→ #{agent_name} Error: #{e.message}")
129
131
  { "error" => e.message, "agent" => agent_name }
130
132
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Agentf
4
- VERSION = "0.4.1"
4
+ VERSION = "0.4.4"
5
5
  end
@@ -166,6 +166,10 @@ module Agentf
166
166
  enriched_context["expected_test_fix"] = @workflow_state.dig("tdd", "failure_signature")
167
167
  end
168
168
 
169
+ if agent_name == Agentf::AgentRoles::REVIEWER
170
+ enriched_context["execution"] = @workflow_state["results"].last&.fetch("result", {}) || {}
171
+ end
172
+
169
173
  result = @provider.execute_agent(
170
174
  agent_name: agent_name,
171
175
  task: @workflow_state["task"],
@@ -179,7 +183,8 @@ module Agentf
179
183
  agent_name: agent_name,
180
184
  boundaries: @agents.fetch(agent_name).class.policy_boundaries,
181
185
  context: enriched_context,
182
- result: result
186
+ result: result,
187
+ phase: :after
183
188
  )
184
189
  append_policy_violations(policy_violations)
185
190
 
data/lib/agentf.rb CHANGED
@@ -15,7 +15,8 @@ module Agentf
15
15
  class Config
16
16
  attr_reader :redis_url
17
17
  attr_accessor :project_name, :base_path, :metrics_enabled, :workflow_contract_enabled,
18
- :workflow_contract_mode, :default_pack, :gem_path
18
+ :workflow_contract_mode, :agent_contract_enabled, :agent_contract_mode,
19
+ :default_pack, :gem_path
19
20
 
20
21
  def initialize
21
22
  @redis_url = normalize_redis_url(ENV.fetch("REDIS_URL", "redis://localhost:6379"))
@@ -29,6 +30,13 @@ module Agentf
29
30
  @workflow_contract_mode = normalize_contract_mode(
30
31
  ENV.fetch("AGENTF_WORKFLOW_CONTRACT_MODE", "advisory")
31
32
  )
33
+ @agent_contract_enabled = parse_boolean(
34
+ ENV.fetch("AGENTF_AGENT_CONTRACT_ENABLED", "true"),
35
+ default: true
36
+ )
37
+ @agent_contract_mode = normalize_contract_mode(
38
+ ENV.fetch("AGENTF_AGENT_CONTRACT_MODE", "enforcing")
39
+ )
32
40
  @default_pack = ENV.fetch("AGENTF_DEFAULT_PACK", "generic").to_s.strip.downcase
33
41
  @gem_path = ENV.fetch("AGENTF_GEM_PATH", nil)
34
42
  end
@@ -81,6 +89,7 @@ require_relative "agentf/service/providers"
81
89
  require_relative "agentf/context_builder"
82
90
  require_relative "agentf/packs"
83
91
  require_relative "agentf/agent_policy"
92
+ require_relative "agentf/agent_execution_contract"
84
93
  require_relative "agentf/workflow_contract"
85
94
  require_relative "agentf/workflow_engine"
86
95
  require_relative "agentf/installer"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: agentf
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.4.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Neal Deters
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-08 00:00:00.000000000 Z
11
+ date: 2026-03-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: redis
@@ -94,6 +94,7 @@ extra_rdoc_files: []
94
94
  files:
95
95
  - bin/agentf
96
96
  - lib/agentf.rb
97
+ - lib/agentf/agent_execution_contract.rb
97
98
  - lib/agentf/agent_policy.rb
98
99
  - lib/agentf/agent_roles.rb
99
100
  - lib/agentf/agents.rb