Skip to content

Commit ca5fc48

Browse files
committed
Merge pull request #184 from CaitlynByrne/feature/blocked-for-human-input
feat: add blocked for human input feature
2 parents f4636fd + d846a02 commit ca5fc48

21 files changed

Lines changed: 591 additions & 56 deletions

agent.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ async def run_autonomous_agent(
222222
# Check if all features are already complete (before starting a new session)
223223
# Skip this check if running as initializer (needs to create features first)
224224
if not is_initializer and iteration == 1:
225-
passing, in_progress, total = count_passing_tests(project_dir)
225+
passing, in_progress, total, _nhi = count_passing_tests(project_dir)
226226
if total > 0 and passing == total:
227227
print("\n" + "=" * 70)
228228
print(" ALL FEATURES ALREADY COMPLETE!")
@@ -348,7 +348,7 @@ async def run_autonomous_agent(
348348
print_progress_summary(project_dir)
349349

350350
# Check if all features are complete - exit gracefully if done
351-
passing, in_progress, total = count_passing_tests(project_dir)
351+
passing, in_progress, total, _nhi = count_passing_tests(project_dir)
352352
if total > 0 and passing == total:
353353
print("\n" + "=" * 70)
354354
print(" ALL FEATURES COMPLETE!")

api/database.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,10 @@ class Feature(Base):
4343

4444
__tablename__ = "features"
4545

46-
# Composite index for common status query pattern (passes, in_progress)
46+
# Composite index for common status query pattern (passes, in_progress, needs_human_input)
4747
# Used by feature_get_stats, get_ready_features, and other status queries
4848
__table_args__ = (
49-
Index('ix_feature_status', 'passes', 'in_progress'),
49+
Index('ix_feature_status', 'passes', 'in_progress', 'needs_human_input'),
5050
)
5151

5252
id = Column(Integer, primary_key=True, index=True)
@@ -61,6 +61,11 @@ class Feature(Base):
6161
# NULL/empty = no dependencies (backwards compatible)
6262
dependencies = Column(JSON, nullable=True, default=None)
6363

64+
# Human input: agent can request structured input from a human
65+
needs_human_input = Column(Boolean, nullable=False, default=False, index=True)
66+
human_input_request = Column(JSON, nullable=True, default=None) # Agent's structured request
67+
human_input_response = Column(JSON, nullable=True, default=None) # Human's response
68+
6469
def to_dict(self) -> dict:
6570
"""Convert feature to dictionary for JSON serialization."""
6671
return {
@@ -75,6 +80,10 @@ def to_dict(self) -> dict:
7580
"in_progress": self.in_progress if self.in_progress is not None else False,
7681
# Dependencies: NULL/empty treated as empty list for backwards compat
7782
"dependencies": self.dependencies if self.dependencies else [],
83+
# Human input fields
84+
"needs_human_input": self.needs_human_input if self.needs_human_input is not None else False,
85+
"human_input_request": self.human_input_request,
86+
"human_input_response": self.human_input_response,
7887
}
7988

8089
def get_dependencies_safe(self) -> list[int]:
@@ -302,6 +311,21 @@ def _is_network_path(path: Path) -> bool:
302311
return False
303312

304313

314+
def _migrate_add_human_input_columns(engine) -> None:
315+
"""Add human input columns to existing databases that don't have them."""
316+
with engine.connect() as conn:
317+
result = conn.execute(text("PRAGMA table_info(features)"))
318+
columns = [row[1] for row in result.fetchall()]
319+
320+
if "needs_human_input" not in columns:
321+
conn.execute(text("ALTER TABLE features ADD COLUMN needs_human_input BOOLEAN DEFAULT 0"))
322+
if "human_input_request" not in columns:
323+
conn.execute(text("ALTER TABLE features ADD COLUMN human_input_request TEXT DEFAULT NULL"))
324+
if "human_input_response" not in columns:
325+
conn.execute(text("ALTER TABLE features ADD COLUMN human_input_response TEXT DEFAULT NULL"))
326+
conn.commit()
327+
328+
305329
def _migrate_add_schedules_tables(engine) -> None:
306330
"""Create schedules and schedule_overrides tables if they don't exist."""
307331
from sqlalchemy import inspect
@@ -425,6 +449,7 @@ def create_database(project_dir: Path) -> tuple:
425449
_migrate_fix_null_boolean_fields(engine)
426450
_migrate_add_dependencies_column(engine)
427451
_migrate_add_testing_columns(engine)
452+
_migrate_add_human_input_columns(engine)
428453

429454
# Migrate to add schedules tables
430455
_migrate_add_schedules_tables(engine)

mcp_server/feature_mcp.py

Lines changed: 115 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -151,17 +151,20 @@ def feature_get_stats() -> str:
151151
result = session.query(
152152
func.count(Feature.id).label('total'),
153153
func.sum(case((Feature.passes == True, 1), else_=0)).label('passing'),
154-
func.sum(case((Feature.in_progress == True, 1), else_=0)).label('in_progress')
154+
func.sum(case((Feature.in_progress == True, 1), else_=0)).label('in_progress'),
155+
func.sum(case((Feature.needs_human_input == True, 1), else_=0)).label('needs_human_input')
155156
).first()
156157

157158
total = result.total or 0
158159
passing = int(result.passing or 0)
159160
in_progress = int(result.in_progress or 0)
161+
needs_human_input = int(result.needs_human_input or 0)
160162
percentage = round((passing / total) * 100, 1) if total > 0 else 0.0
161163

162164
return json.dumps({
163165
"passing": passing,
164166
"in_progress": in_progress,
167+
"needs_human_input": needs_human_input,
165168
"total": total,
166169
"percentage": percentage
167170
})
@@ -221,6 +224,7 @@ def feature_get_summary(
221224
"name": feature.name,
222225
"passes": feature.passes,
223226
"in_progress": feature.in_progress,
227+
"needs_human_input": feature.needs_human_input if feature.needs_human_input is not None else False,
224228
"dependencies": feature.dependencies or []
225229
})
226230
finally:
@@ -401,11 +405,11 @@ def feature_mark_in_progress(
401405
"""
402406
session = get_session()
403407
try:
404-
# Atomic claim: only succeeds if feature is not already claimed or passing
408+
# Atomic claim: only succeeds if feature is not already claimed, passing, or blocked for human input
405409
result = session.execute(text("""
406410
UPDATE features
407411
SET in_progress = 1
408-
WHERE id = :id AND passes = 0 AND in_progress = 0
412+
WHERE id = :id AND passes = 0 AND in_progress = 0 AND needs_human_input = 0
409413
"""), {"id": feature_id})
410414
session.commit()
411415

@@ -418,6 +422,8 @@ def feature_mark_in_progress(
418422
return json.dumps({"error": f"Feature with ID {feature_id} is already passing"})
419423
if feature.in_progress:
420424
return json.dumps({"error": f"Feature with ID {feature_id} is already in-progress"})
425+
if getattr(feature, 'needs_human_input', False):
426+
return json.dumps({"error": f"Feature with ID {feature_id} is blocked waiting for human input"})
421427
return json.dumps({"error": "Failed to mark feature in-progress for unknown reason"})
422428

423429
# Fetch the claimed feature
@@ -455,11 +461,14 @@ def feature_claim_and_get(
455461
if feature.passes:
456462
return json.dumps({"error": f"Feature with ID {feature_id} is already passing"})
457463

458-
# Try atomic claim: only succeeds if not already claimed
464+
if getattr(feature, 'needs_human_input', False):
465+
return json.dumps({"error": f"Feature with ID {feature_id} is blocked waiting for human input"})
466+
467+
# Try atomic claim: only succeeds if not already claimed and not blocked for human input
459468
result = session.execute(text("""
460469
UPDATE features
461470
SET in_progress = 1
462-
WHERE id = :id AND passes = 0 AND in_progress = 0
471+
WHERE id = :id AND passes = 0 AND in_progress = 0 AND needs_human_input = 0
463472
"""), {"id": feature_id})
464473
session.commit()
465474

@@ -806,6 +815,8 @@ def feature_get_ready(
806815
for f in all_features:
807816
if f.passes or f.in_progress:
808817
continue
818+
if getattr(f, 'needs_human_input', False):
819+
continue
809820
deps = f.dependencies or []
810821
if all(dep_id in passing_ids for dep_id in deps):
811822
ready.append(f.to_dict())
@@ -888,6 +899,8 @@ def feature_get_graph() -> str:
888899

889900
if f.passes:
890901
status = "done"
902+
elif getattr(f, 'needs_human_input', False):
903+
status = "needs_human_input"
891904
elif blocking:
892905
status = "blocked"
893906
elif f.in_progress:
@@ -984,6 +997,103 @@ def feature_set_dependencies(
984997
return json.dumps({"error": f"Failed to set dependencies: {str(e)}"})
985998

986999

1000+
@mcp.tool()
1001+
def feature_request_human_input(
1002+
feature_id: Annotated[int, Field(description="The ID of the feature that needs human input", ge=1)],
1003+
prompt: Annotated[str, Field(min_length=1, description="Explain what you need from the human and why")],
1004+
fields: Annotated[list[dict], Field(min_length=1, description="List of input fields to collect")]
1005+
) -> str:
1006+
"""Request structured input from a human for a feature that is blocked.
1007+
1008+
Use this ONLY when the feature genuinely cannot proceed without human intervention:
1009+
- Creating API keys or external accounts
1010+
- Choosing between design approaches that require human preference
1011+
- Configuring external services the agent cannot access
1012+
- Providing credentials or secrets
1013+
1014+
Do NOT use this for issues you can solve yourself (debugging, reading docs, etc.).
1015+
1016+
The feature will be moved out of in_progress and into a "needs human input" state.
1017+
Once the human provides their response, the feature returns to the pending queue
1018+
and will include the human's response when you pick it up again.
1019+
1020+
Args:
1021+
feature_id: The ID of the feature that needs human input
1022+
prompt: A clear explanation of what you need and why
1023+
fields: List of input fields, each with:
1024+
- id (str): Unique field identifier
1025+
- label (str): Human-readable label
1026+
- type (str): "text", "textarea", "select", or "boolean" (default: "text")
1027+
- required (bool): Whether the field is required (default: true)
1028+
- placeholder (str, optional): Placeholder text
1029+
- options (list, optional): For select type: [{value, label}]
1030+
1031+
Returns:
1032+
JSON with success confirmation or error message
1033+
"""
1034+
# Validate fields
1035+
VALID_FIELD_TYPES = {"text", "textarea", "select", "boolean"}
1036+
seen_ids: set[str] = set()
1037+
for i, field in enumerate(fields):
1038+
if "id" not in field or "label" not in field:
1039+
return json.dumps({"error": f"Field at index {i} missing required 'id' or 'label'"})
1040+
fid = field["id"]
1041+
flabel = field["label"]
1042+
if not isinstance(fid, str) or not fid.strip():
1043+
return json.dumps({"error": f"Field at index {i} has empty or invalid 'id'"})
1044+
if not isinstance(flabel, str) or not flabel.strip():
1045+
return json.dumps({"error": f"Field at index {i} has empty or invalid 'label'"})
1046+
if fid in seen_ids:
1047+
return json.dumps({"error": f"Duplicate field id '{fid}' at index {i}"})
1048+
seen_ids.add(fid)
1049+
ftype = field.get("type", "text")
1050+
if ftype not in VALID_FIELD_TYPES:
1051+
return json.dumps({"error": f"Field at index {i} has invalid type '{ftype}'. Must be one of: {', '.join(sorted(VALID_FIELD_TYPES))}"})
1052+
if ftype == "select" and not field.get("options"):
1053+
return json.dumps({"error": f"Field at index {i} is type 'select' but missing 'options' array"})
1054+
1055+
request_data = {
1056+
"prompt": prompt,
1057+
"fields": fields,
1058+
}
1059+
1060+
session = get_session()
1061+
try:
1062+
# Atomically set needs_human_input, clear in_progress, store request, clear previous response
1063+
result = session.execute(text("""
1064+
UPDATE features
1065+
SET needs_human_input = 1,
1066+
in_progress = 0,
1067+
human_input_request = :request,
1068+
human_input_response = NULL
1069+
WHERE id = :id AND passes = 0 AND in_progress = 1
1070+
"""), {"id": feature_id, "request": json.dumps(request_data)})
1071+
session.commit()
1072+
1073+
if result.rowcount == 0:
1074+
feature = session.query(Feature).filter(Feature.id == feature_id).first()
1075+
if feature is None:
1076+
return json.dumps({"error": f"Feature with ID {feature_id} not found"})
1077+
if feature.passes:
1078+
return json.dumps({"error": f"Feature with ID {feature_id} is already passing"})
1079+
if not feature.in_progress:
1080+
return json.dumps({"error": f"Feature with ID {feature_id} is not in progress"})
1081+
return json.dumps({"error": "Failed to request human input for unknown reason"})
1082+
1083+
feature = session.query(Feature).filter(Feature.id == feature_id).first()
1084+
return json.dumps({
1085+
"success": True,
1086+
"feature_id": feature_id,
1087+
"name": feature.name,
1088+
"message": f"Feature '{feature.name}' is now blocked waiting for human input"
1089+
})
1090+
except Exception as e:
1091+
session.rollback()
1092+
return json.dumps({"error": f"Failed to request human input: {str(e)}"})
1093+
finally:
1094+
session.close()
1095+
1096+
9871097
@mcp.tool()
9881098
def ask_user(
9891099
questions: Annotated[list[dict], Field(description="List of questions to ask, each with question, header, options (list of {label, description}), and multiSelect (bool)")]

parallel_orchestrator.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,9 @@ def get_resumable_features(
496496
for fd in feature_dicts:
497497
if not fd.get("in_progress") or fd.get("passes"):
498498
continue
499+
# Skip if blocked for human input
500+
if fd.get("needs_human_input"):
501+
continue
499502
# Skip if already running in this orchestrator instance
500503
if fd["id"] in running_ids:
501504
continue
@@ -540,11 +543,14 @@ def get_ready_features(
540543
running_ids.update(batch_ids)
541544

542545
ready = []
543-
skipped_reasons = {"passes": 0, "in_progress": 0, "running": 0, "failed": 0, "deps": 0}
546+
skipped_reasons = {"passes": 0, "in_progress": 0, "running": 0, "failed": 0, "deps": 0, "needs_human_input": 0}
544547
for fd in feature_dicts:
545548
if fd.get("passes"):
546549
skipped_reasons["passes"] += 1
547550
continue
551+
if fd.get("needs_human_input"):
552+
skipped_reasons["needs_human_input"] += 1
553+
continue
548554
if fd.get("in_progress"):
549555
skipped_reasons["in_progress"] += 1
550556
continue

0 commit comments

Comments
 (0)