Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui
This commit is contained in:
+11
-1
@@ -94,10 +94,20 @@ _HERMES_ENV_PATH = (
|
||||
)
|
||||
_PROJECT_ENV_PATH = r'(?:(?:/|\.{1,2}/)?(?:[^\s/"\'`]+/)*\.env(?:\.[^/\s"\'`]+)*)'
|
||||
_PROJECT_CONFIG_PATH = r'(?:(?:/|\.{1,2}/)?(?:[^\s/"\'`]+/)*config\.yaml)'
|
||||
_SHELL_RC_FILES = (
|
||||
r'(?:~|\$home|\$\{home\})/\.'
|
||||
r'(?:bashrc|zshrc|profile|bash_profile|zprofile)\b'
|
||||
)
|
||||
_CREDENTIAL_FILES = (
|
||||
r'(?:~|\$home|\$\{home\})/\.'
|
||||
r'(?:netrc|pgpass|npmrc|pypirc)\b'
|
||||
)
|
||||
_SENSITIVE_WRITE_TARGET = (
|
||||
r'(?:/etc/|/dev/sd|'
|
||||
rf'{_SSH_SENSITIVE_PATH}|'
|
||||
rf'{_HERMES_ENV_PATH})'
|
||||
rf'{_HERMES_ENV_PATH}|'
|
||||
rf'{_SHELL_RC_FILES}|'
|
||||
rf'{_CREDENTIAL_FILES})'
|
||||
)
|
||||
_PROJECT_SENSITIVE_WRITE_TARGET = rf'(?:{_PROJECT_ENV_PATH}|{_PROJECT_CONFIG_PATH})'
|
||||
_COMMAND_TAIL = r'(?:\s*(?:&&|\|\||;).*)?$'
|
||||
|
||||
+19
-1
@@ -1097,7 +1097,25 @@ def _handle_read_file(args, **kw):
|
||||
|
||||
def _handle_write_file(args, **kw):
|
||||
tid = kw.get("task_id") or "default"
|
||||
return write_file_tool(path=args.get("path", ""), content=args.get("content", ""), task_id=tid)
|
||||
if not args.get("path") or not isinstance(args.get("path"), str):
|
||||
return tool_error(
|
||||
"write_file: missing required field 'path'. Re-emit the tool call with "
|
||||
"both 'path' and 'content' set."
|
||||
)
|
||||
if "content" not in args:
|
||||
return tool_error(
|
||||
"write_file: missing required field 'content'. The tool call included a "
|
||||
"path but no content argument — this is almost always a dropped-arg bug "
|
||||
"under context pressure. Re-emit the tool call with the full content "
|
||||
"payload, or use execute_code with hermes_tools.write_file() for very "
|
||||
"large files."
|
||||
)
|
||||
if not isinstance(args["content"], str):
|
||||
return tool_error(
|
||||
f"write_file: 'content' must be a string, got "
|
||||
f"{type(args['content']).__name__}."
|
||||
)
|
||||
return write_file_tool(path=args["path"], content=args["content"], task_id=tid)
|
||||
|
||||
|
||||
def _handle_patch(args, **kw):
|
||||
|
||||
@@ -560,8 +560,18 @@ def _patch_skill(
|
||||
}
|
||||
|
||||
|
||||
def _delete_skill(name: str) -> Dict[str, Any]:
|
||||
"""Delete a skill."""
|
||||
def _delete_skill(name: str, absorbed_into: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Delete a skill.
|
||||
|
||||
``absorbed_into`` declares intent:
|
||||
- ``None`` / missing → caller didn't declare (legacy / non-curator path);
|
||||
accepted for backward compat but logs a warning because the curator
|
||||
classification pipeline can't tell consolidation from pruning without it.
|
||||
- ``""`` (empty) → explicit "truly pruned, no forwarding target".
|
||||
- ``"<skill-name>"`` → content was absorbed into that umbrella; the
|
||||
target must exist on disk. Validated here so the model can't claim an
|
||||
umbrella that doesn't exist.
|
||||
"""
|
||||
existing = _find_skill(name)
|
||||
if not existing:
|
||||
return {"success": False, "error": f"Skill '{name}' not found."}
|
||||
@@ -570,6 +580,24 @@ def _delete_skill(name: str) -> Dict[str, Any]:
|
||||
if pinned_err:
|
||||
return {"success": False, "error": pinned_err}
|
||||
|
||||
# Validate absorbed_into target when declared non-empty
|
||||
if absorbed_into is not None and isinstance(absorbed_into, str) and absorbed_into.strip():
|
||||
target_name = absorbed_into.strip()
|
||||
if target_name == name:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"absorbed_into='{target_name}' cannot equal the skill being deleted.",
|
||||
}
|
||||
target = _find_skill(target_name)
|
||||
if not target:
|
||||
return {
|
||||
"success": False,
|
||||
"error": (
|
||||
f"absorbed_into='{target_name}' does not exist. "
|
||||
f"Create or patch the umbrella skill first, then retry the delete."
|
||||
),
|
||||
}
|
||||
|
||||
skill_dir = existing["path"]
|
||||
skills_root = _containing_skills_root(skill_dir)
|
||||
shutil.rmtree(skill_dir)
|
||||
@@ -579,9 +607,13 @@ def _delete_skill(name: str) -> Dict[str, Any]:
|
||||
if parent != skills_root and parent.exists() and not any(parent.iterdir()):
|
||||
parent.rmdir()
|
||||
|
||||
message = f"Skill '{name}' deleted."
|
||||
if absorbed_into is not None and isinstance(absorbed_into, str) and absorbed_into.strip():
|
||||
message += f" Content absorbed into '{absorbed_into.strip()}'."
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Skill '{name}' deleted.",
|
||||
"message": message,
|
||||
}
|
||||
|
||||
|
||||
@@ -702,6 +734,7 @@ def skill_manage(
|
||||
old_string: str = None,
|
||||
new_string: str = None,
|
||||
replace_all: bool = False,
|
||||
absorbed_into: str = None,
|
||||
) -> str:
|
||||
"""
|
||||
Manage user-created skills. Dispatches to the appropriate action handler.
|
||||
@@ -726,7 +759,7 @@ def skill_manage(
|
||||
result = _patch_skill(name, old_string, new_string, file_path, replace_all)
|
||||
|
||||
elif action == "delete":
|
||||
result = _delete_skill(name)
|
||||
result = _delete_skill(name, absorbed_into=absorbed_into)
|
||||
|
||||
elif action == "write_file":
|
||||
if not file_path:
|
||||
@@ -778,6 +811,13 @@ SKILL_MANAGE_SCHEMA = {
|
||||
"patch (old_string/new_string — preferred for fixes), "
|
||||
"edit (full SKILL.md rewrite — major overhauls only), "
|
||||
"delete, write_file, remove_file.\n\n"
|
||||
"On delete, pass `absorbed_into=<umbrella>` when you're merging this "
|
||||
"skill's content into another one, or `absorbed_into=\"\"` when you're "
|
||||
"pruning it with no forwarding target. This lets the curator tell "
|
||||
"consolidation from pruning without guessing, so downstream consumers "
|
||||
"(cron jobs that reference the old skill name, etc.) get updated "
|
||||
"correctly. The target you name in `absorbed_into` must already "
|
||||
"exist — create/patch the umbrella first, then delete.\n\n"
|
||||
"Create when: complex task succeeded (5+ calls), errors overcome, "
|
||||
"user-corrected approach worked, non-trivial workflow discovered, "
|
||||
"or user asks you to remember a procedure.\n"
|
||||
@@ -855,6 +895,20 @@ SKILL_MANAGE_SCHEMA = {
|
||||
"type": "string",
|
||||
"description": "Content for the file. Required for 'write_file'."
|
||||
},
|
||||
"absorbed_into": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"For 'delete' only — declares intent so the curator can "
|
||||
"tell consolidation from pruning without guessing. "
|
||||
"Pass the umbrella skill name when this skill's content "
|
||||
"was merged into another (the target must already exist). "
|
||||
"Pass an empty string when the skill is truly stale and "
|
||||
"being pruned with no forwarding target. Omitting the arg "
|
||||
"on delete is supported for backward compatibility but "
|
||||
"downstream tooling (e.g. cron-job skill reference "
|
||||
"rewriting) will have to guess at intent."
|
||||
)
|
||||
},
|
||||
},
|
||||
"required": ["action", "name"],
|
||||
},
|
||||
@@ -877,6 +931,7 @@ registry.register(
|
||||
file_content=args.get("file_content"),
|
||||
old_string=args.get("old_string"),
|
||||
new_string=args.get("new_string"),
|
||||
replace_all=args.get("replace_all", False)),
|
||||
replace_all=args.get("replace_all", False),
|
||||
absorbed_into=args.get("absorbed_into")),
|
||||
emoji="📝",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user