@@ -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 ()
9881098def 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)" )]
0 commit comments