From af5fbfabecb97d7ec5701daf4a6bd8707ba96cf2 Mon Sep 17 00:00:00 2001 From: HyunjunJeon Date: Sun, 11 Jan 2026 17:55:52 +0900 Subject: [PATCH] =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=B6=94=EA=B0=80:=20Cont?= =?UTF-8?q?ext=20Engineering=20=EB=AC=B8=EC=84=9C=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20deepagents=5Fsourcecode=20=ED=95=9C=EA=B5=AD?= =?UTF-8?q?=EC=96=B4=20=EB=B2=88=EC=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Context_Engineering.md: 에이전트 컨텍스트 엔지니어링 개념 정리 문서 추가 - Context_Engineering_Research.ipynb: 연구 노트북 업데이트 - deepagents_sourcecode/: docstring과 주석을 한국어로 번역 --- Context_Engineering.md | 211 ++++ Context_Engineering_Research.ipynb | 1120 ++++++++++++++++- .../examples/ralph_mode/ralph_mode.py | 35 +- .../libs/acp/deepagents_acp/__init__.py | 1 + .../libs/acp/deepagents_acp/server.py | 96 +- .../libs/acp/tests/__init__.py | 1 + .../libs/acp/tests/chat_model.py | 17 +- .../libs/acp/tests/test_server.py | 17 +- .../deepagents-cli/deepagents_cli/__init__.py | 2 +- .../deepagents-cli/deepagents_cli/__main__.py | 5 +- .../deepagents-cli/deepagents_cli/_version.py | 5 +- .../deepagents-cli/deepagents_cli/agent.py | 41 +- .../libs/deepagents-cli/deepagents_cli/app.py | 52 +- .../deepagents_cli/clipboard.py | 20 +- .../deepagents-cli/deepagents_cli/config.py | 34 +- .../deepagents-cli/deepagents_cli/file_ops.py | 27 +- .../deepagents_cli/image_utils.py | 94 +- .../deepagents-cli/deepagents_cli/input.py | 51 +- .../deepagents_cli/integrations/__init__.py | 5 +- .../deepagents_cli/integrations/daytona.py | 26 +- .../deepagents_cli/integrations/modal.py | 5 +- .../deepagents_cli/integrations/runloop.py | 10 +- .../integrations/sandbox_factory.py | 28 +- .../deepagents-cli/deepagents_cli/main.py | 11 +- .../deepagents_cli/project_utils.py | 12 +- .../deepagents-cli/deepagents_cli/sessions.py | 5 +- .../deepagents-cli/deepagents_cli/shell.py | 10 +- .../deepagents_cli/skills/__init__.py | 2 +- .../deepagents_cli/skills/commands.py | 64 +- .../deepagents_cli/skills/load.py | 7 +- .../deepagents_cli/textual_adapter.py | 8 +- .../deepagents-cli/deepagents_cli/tools.py | 62 +- .../libs/deepagents-cli/deepagents_cli/ui.py | 49 +- .../deepagents_cli/widgets/__init__.py | 5 +- .../deepagents_cli/widgets/approval.py | 24 +- .../deepagents_cli/widgets/autocomplete.py | 2 +- .../deepagents_cli/widgets/chat_input.py | 5 +- .../deepagents_cli/widgets/diff.py | 7 +- .../deepagents_cli/widgets/history.py | 36 +- .../deepagents_cli/widgets/loading.py | 5 +- .../deepagents_cli/widgets/messages.py | 7 +- .../deepagents_cli/widgets/status.py | 42 +- .../deepagents_cli/widgets/tool_renderers.py | 35 +- .../deepagents_cli/widgets/tool_widgets.py | 5 +- .../deepagents_cli/widgets/welcome.py | 5 +- .../skills/arxiv-search/arxiv_search.py | 14 +- .../skill-creator/scripts/init_skill.py | 104 +- .../skill-creator/scripts/quick_validate.py | 75 +- .../integration_tests/test_sandbox_factory.py | 66 +- .../test_sandbox_operations.py | 19 +- .../tests/unit_tests/skills/test_commands.py | 2 +- .../tests/unit_tests/test_autocomplete.py | 90 +- .../tests/unit_tests/test_end_to_end.py | 21 +- .../tests/unit_tests/test_sessions.py | 36 +- .../libs/deepagents/deepagents/__init__.py | 2 +- .../deepagents/backends/__init__.py | 2 +- .../deepagents/backends/composite.py | 229 ++-- .../deepagents/backends/filesystem.py | 78 +- .../deepagents/backends/protocol.py | 230 ++-- .../deepagents/deepagents/backends/sandbox.py | 67 +- .../deepagents/deepagents/backends/state.py | 82 +- .../deepagents/deepagents/backends/store.py | 102 +- .../deepagents/deepagents/backends/utils.py | 119 +- .../libs/deepagents/deepagents/graph.py | 85 +- .../deepagents/middleware/__init__.py | 2 +- .../deepagents/middleware/filesystem.py | 273 ++-- .../deepagents/middleware/memory.py | 134 +- .../deepagents/middleware/patch_tool_calls.py | 10 +- .../deepagents/middleware/skills.py | 162 ++- .../deepagents/middleware/subagents.py | 87 +- .../libs/harbor/deepagents_harbor/backend.py | 29 +- .../deepagents_harbor/deepagents_wrapper.py | 27 +- .../libs/harbor/deepagents_harbor/tracing.py | 12 +- .../libs/harbor/scripts/analyze.py | 6 +- .../libs/harbor/scripts/harbor_langsmith.py | 7 +- pyproject.toml | 14 +- 76 files changed, 3026 insertions(+), 1471 deletions(-) create mode 100644 Context_Engineering.md diff --git a/Context_Engineering.md b/Context_Engineering.md new file mode 100644 index 0000000..2e22232 --- /dev/null +++ b/Context_Engineering.md @@ -0,0 +1,211 @@ +# Context Engineering + +**Context Engineering(컨텍스트 엔지니어링)**을 “에이전트를 실제로 잘 동작하게 만드는 문맥 설계/운영 기술”로 정리합니다. + +- YouTube: https://www.youtube.com/watch?v=6_BcCthVvb8 +- PDF: `Context Engineering LangChain.pdf` +- PDF: `Manus Context Engineering LangChain Webinar.pdf` + +--- + +## 1) Context Engineering이란? + +YouTube 발표에서는 Context Engineering을 다음처럼 정의합니다. + +- **“다음 스텝에 필요한 ‘딱 그 정보’만을 컨텍스트 윈도우에 채워 넣는 섬세한 기술(art)과 과학(science)”** + (영상 약 203초 부근: “the delicate art and science of filling the context window…”) + +이 정의는 “좋은 프롬프트 한 줄”의 문제가 아니라, **에이전트가 수십~수백 턴을 거치며(tool call 포함) 컨텍스트가 폭발적으로 커지는 상황에서**: + +- 무엇을 **남기고** +- 무엇을 **버리고** +- 무엇을 **외부로 빼고(오프로딩)** +- 무엇을 **필요할 때만 다시 가져오며(리트리벌)** +- 무엇을 **격리하고(아이솔레이션)** +- 무엇을 **캐시로 비용/지연을 줄이는지(캐싱)** + +를 체계적으로 설계/운영하는 문제로 확장됩니다. (LangChain PDF: “Context grows w/ agents”, “Typical task… 50 tool calls”, “hundreds of turns”) + +--- + +## 2) 왜 지금 “Context Engineering”인가? + +### 2.1 에이전트에서 컨텍스트는 ‘성장’한다 + +LangChain PDF는 에이전트가 등장하면서 컨텍스트가 본질적으로 커진다고 강조합니다. + +- 한 작업이 **많은 도구 호출(tool calls)**을 필요로 하고(예: Manus의 “around 50 tool calls”), +- 운영 환경에서는 **수백 턴**의 대화/관찰이 누적됩니다. + +### 2.2 컨텍스트가 커질수록 성능이 떨어질 수 있다 (“context rot”) + +LangChain PDF는 `context-rot` 자료를 인용하며 **컨텍스트 길이가 늘수록 성능이 하락할 수 있음**을 지적합니다. +즉, “더 많이 넣으면 더 똑똑해진다”는 직관이 깨집니다. + +### 2.3 컨텍스트 실패 모드(실패 유형)들이 반복 관측된다 + +LangChain PDF는 컨텍스트가 커질 때의 대표적인 실패를 4가지로 제시합니다. + +- **Context Poisoning**: 잘못된 정보가 섞여 이후 의사결정/행동을 오염 +- **Context Distraction**: 중요한 목표보다 반복/쉬운 패턴에 끌림(장기 컨텍스트에서 새 계획보다 반복 행동 선호 등) +- **Context Confusion**: 도구가 많고 비슷할수록 잘못된 도구 호출/비존재 도구 호출 등 혼란 증가 +- **Context Clash**: 연속된 관찰/도구 결과가 서로 모순될 때 성능 하락 + +이 실패 모드들은 “프롬프트를 더 잘 쓰자”로는 해결이 어렵고, **컨텍스트의 구조·유지·정리·검증 전략**이 필요합니다. + +### 2.4 Manus 관점: “모델을 건드리기보다 컨텍스트를 설계하라” + +Manus PDF는 Context Engineering을 “앱과 모델 사이의 가장 실용적인 경계”라고 강조합니다. + +- “Context Engineering is the clearest and most practical boundary between application and model.” (Manus PDF) + +이 관점에서 Manus는 제품 개발 과정에서 흔히 빠지는 2가지 함정을 지적합니다. + +- **The First Trap**: “차라리 우리 모델을 따로 학습(파인튜닝)하지?”라는 유혹 + → 현실적으로 모델 반복 속도가 제품 반복 속도를 제한할 수 있고(PMF 이전에는 특히), 이 선택이 제품 개발을 늦출 수 있음 +- **The Second Trap**: “액션 스페이스+리워드+롤아웃(RL)로 최적화하자” + → 하지만 이후 MCP 같은 확장 요구가 들어오면 다시 설계를 뒤엎게 될 수 있으니, 기반 모델 회사가 이미 잘하는 영역을 불필요하게 재구축하지 말 것 + +요약하면, “모델을 바꾸는 일”을 서두르기 전에 **컨텍스트를 어떻게 구성/축소/격리/리트리벌할지**를 먼저 해결하라는 메시지입니다. + +--- + +## 3) Context Engineering의 5가지 핵심 레버(지렛대) + +LangChain PDF(및 영상 전개)는 공통적으로 다음 5가지를 핵심 테마로 제시합니다. + +### 3.1 Offload (컨텍스트 오프로딩) + +**핵심 아이디어**: 모든 정보를 메시지 히스토리에 남길 필요가 없습니다. 토큰이 큰 결과는 **파일시스템/외부 저장소로 보내고**, 요약/참조(포인터)만 남깁니다. + +- LangChain PDF: “Use file system for notes / todo.md / tok-heavy context / long-term memories” +- 영상: “you don't need all context to live in this messages history… offload it… it can be retrieved later” + +**실무 패턴** +- 대용량 tool output은 파일로 저장하고, 대화에는 `저장 경로 + 요약 + 재로드 방법`만 남기기 +- “작업 브리프/계획(todo)”를 파일로 유지해 긴 대화에서도 방향성을 잃지 않기 + +### 3.2 Reduce (컨텍스트 축소: Prune/Compaction/Summarization) + +**핵심 아이디어**: 컨텍스트가 커질수록 성능/비용이 악화될 수 있으므로, “넣는 기술”뿐 아니라 “빼는 기술”이 필요합니다. + +LangChain PDF는 “Summarize / prune message history”, “Summarize / prune tool call outputs”를 언급하면서도, 정보 손실 위험을 경고합니다. + +Manus PDF는 **Compaction vs Summarization**을 별도 토픽으로 둡니다. + +- **Compaction(압축/정리)**: 불필요한 원문을 제거하거나, 구조화된 형태로 재배치/정돈해 “같은 의미를 더 적은 토큰으로” 담기 +- **Summarization(요약)**: 모델이 자연어 요약을 생성해 토큰을 줄이되, 정보 손실 가능 + +**실무 패턴** +- “도구 결과 원문”은 저장(Offload)하고 대화에는 “관찰(Observations) 요약”만 유지 +- 일정 기준(예: 컨텍스트 사용량 임계치, 턴 수, 주기)마다 요약/정리 실행 +- 요약은 “결정/근거/미해결 질문/다음 행동” 같은 스키마로 구조화(손실 최소화) + +### 3.3 Retrieve (필요할 때만 가져오기) + +**핵심 아이디어**: 오프로딩/축소로 비운 자리를 “아무거나로 채우지 말고”, **현재 스텝에 필요한 것만** 검색·리트리벌로 가져옵니다. + +LangChain PDF는 “Mix of retrieval methods + re-ranking”, “Systems to assemble retrievals into prompts”, “Retrieve relevant tools based upon tool descriptions” 등 “검색 결과를 프롬프트에 조립하는 시스템”을 강조합니다. + +**실무 패턴** +- 파일/노트/로그에서 grep/glob/키워드 기반 검색(결정적이고 디버그 가능) +- 리트리벌 결과는 “왜 가져왔는지(근거)”와 함께 삽입해 모델이 사용 이유를 이해하도록 구성 +- 도구 설명도 리트리벌 대상: “필요한 도구만 로딩”하여 혼란(Context Confusion) 완화 + +### 3.4 Isolate (컨텍스트 격리) + +**핵심 아이디어**: 한 컨텍스트에 모든 역할을 때려 넣으면 오염/충돌이 늘어납니다. 작업을 쪼개 **서브 에이전트(서브 컨텍스트)**로 격리합니다. + +LangChain PDF는 multi-agent로 컨텍스트를 분리하되, 의사결정 충돌 위험을 경고합니다(“Multi-agents make conflicting decisions…”). +Manus PDF는 “언어/동시성(concurrency)에서 배운 지혜”를 빌려오며, Go 블로그의 문구를 인용합니다. + +- “Do not communicate by sharing memory; instead, share memory by communicating.” (Manus PDF) + +즉, **공유 메모리(거대한 공용 컨텍스트)**로 동기화하려 하지 말고, +역할별 컨텍스트를 분리한 뒤 **명시적 메시지/산출물(요약, 브리프, 결과물)**로만 조율하라는 뜻입니다. + +### 3.5 Cache (반복 컨텍스트 캐싱) + +**핵심 아이디어**: 에이전트는 시스템 프롬프트/도구 설명/정책 같은 “상대적으로 불변(stable)”한 토큰이 반복됩니다. 이를 캐싱하면 비용과 지연을 크게 줄일 수 있습니다. + +LangChain PDF: +- “Cache agent instructions, tool descriptions to prefix” +- “Add mutable context / recent observations to suffix” +- (특정 프로바이더 기준) “Cached input tokens … 10x cheaper!” 같은 비용 레버를 언급 + +**실무 패턴** +- 프롬프트를 **prefix(불변: 지침/도구/정책)** + **suffix(가변: 최근 관찰/상태)**로 분리해 캐시 효율 극대화 +- 캐시 안정성을 해치지 않도록: 자주 변하는 내용을 system/prefix에 섞지 않기 + +--- + +## 4) “도구”도 컨텍스트를 더럽힌다: Tool Offloading과 계층적 액션 스페이스 + +Manus PDF는 오프로딩을 “메모리(파일)로만” 생각하면 부족하다고 말합니다. + +- “tools themselves also clutter context” +- “Too many tools → confusion, invalid calls” + +그래서 **도구 자체도 오프로딩/계층화**해야 한다고 제안합니다(“Offload tools through a hierarchical action space”). + +Manus PDF가 제시하는 3단계 추상화(계층): + +1. **Function Calling** + - 장점: 스키마 안전, 표준적 + - 단점: 변경 시 캐시가 자주 깨짐, 도구가 많아지면 혼란 증가(Context Confusion) +2. **Sandbox Utilities (CLI/셸 유틸리티)** + - 모델 컨텍스트를 바꾸지 않고도 기능을 확장 가능 + - 큰 출력은 파일로 저장시키기 쉬움(오프로딩과 결합) +3. **Packages & APIs (스크립트로 사전 승인된 API 호출)** + - 데이터가 크거나 연쇄 호출이 필요한 작업에 유리 + - 목표: “Keep model context clean, use memory for reasoning only.” + +요지는 “모든 것을 함수 목록으로 나열”하는 대신, **상위 레벨의 안정적인 인터페이스**를 제공해 컨텍스트를 작게 유지하고, 필요 시 하위 레벨로 내려가게 만드는 설계입니다. + +--- + +## 5) 운영 관점 체크리스트 (실무 적용용) + +아래는 위 소스들의 공통 테마를 “운영 가능한 체크리스트”로 정리한 것입니다. + +1. **컨텍스트 예산(Budget) 정의**: 모델 윈도우/비용/지연을 고려해 “언제 줄일지” 임계치를 정한다. +2. **오프로딩 기본값**: 큰 tool output은 원문을 남기지 말고 파일/스토리지로 보내며, 포인터를 남긴다. +3. **축소 전략 계층화**: (가능하면) Compaction/Prune → Summarization 순으로 비용·손실을 제어한다. +4. **리트리벌 조립(Assembly)**: 검색은 끝이 아니라 시작이다. “무엇을, 왜, 어떤 순서로” 넣는 조립 로직이 필요하다. +5. **격리 기준 수립**: “오염될 수 있는 작업(탐색/긴 출력/다단계 분석)”을 서브 에이전트로 분리한다. +6. **캐시 친화 프롬프트**: prefix(불변) / suffix(가변)로 분리한다. +7. **실패 모드 방어**: Poisoning/Distraction/Confusion/Clash를 로그로 관측하고, 완화책(정리·검증·도구 로딩 제한·모순 검사)을 붙인다. +8. **과잉 엔지니어링 경계**: Manus PDF의 경고처럼 “더 많은 컨텍스트 ≠ 더 많은 지능”. 최대 성과는 종종 “추가”가 아니라 “제거”에서 나온다. + +--- + +## 6) `Context_Engineering_Research.ipynb` 평가: 이 문서를 ‘잘 표현’하고 있는가? + +### 결론(요약) + +- **5대 전략(Offload/Reduce/Retrieve/Isolate/Cache)을 설명하는 데는 충분히 좋습니다.** +- 최근 업데이트로 **“왜 문제인지”, 실패 모드, Tool Offloading** 개요가 추가되어 설명력은 좋아졌지만, 이를 뒷받침하는 **재현 실험/운영 규칙**은 여전히 보완이 필요합니다. + +### 잘 표현하는 부분 + +- **5가지 핵심 전략을 명시적으로 구조화**해 설명합니다(표 + 섹션 구성). +- Offloading/Reduction/Retrieval/Isolation/Caching을 각각: + - “원리(트리거/효과)” + - “구현(DeepAgents 미들웨어/설정)” + - “간단한 시뮬레이션 실험” + 로 연결해, “개념 → 구현” 흐름이 명확합니다. + +### 부족한 부분(이 문서 대비 갭) + +- **실패 모드 4종(Poisoning/Distraction/Confusion/Clash)**을 “설명”하긴 하지만, 노트북의 **실험/설계에 직접 반영**(재현→완화 비교)되어 있지 않습니다. +- Manus PDF의 중요한 관점인 **“도구도 컨텍스트를 더럽힌다”**(Tool Offloading)도 개요는 추가되었지만, **도구 과다/유사 도구로 인한 혼란**을 줄이는 실험이 없습니다. +- Manus PDF의 메시지인 **“과잉 컨텍스트/과잉 설계 경계(‘Removing, not adding’)”**가 체크리스트/의사결정 가이드로 정리되어 있지 않습니다. + +### “Context Engineering 설명”을 완성도 높게 만들기 위한 보완 제안(노트북 기준) + +노트북을 “5가지 전략 데모”에서 “Context Engineering 설명서”로 끌어올리려면, 아래 4가지만 추가해도 체감이 큽니다. + +1. (완료) **서론 강화(문제 정의)**: 컨텍스트 성장, context rot, 실패 모드 4종을 첫 섹션에서 명시. +2. (추가됨 - 시뮬레이션) **실패 모드별 미니 실험**: Confusion(도구 과다/유사 도구)·Clash(모순 tool output)·Distraction(장기 로그에서 반복행동)·Poisoning(검증되지 않은 사실) 재현/완화 시뮬레이션 추가. +3. (완료) **Tool Offloading/계층 설계 섹션**: “도구를 리트리벌로 로딩/제한”하거나 “상위 래퍼 도구로 단순화”하는 패턴 소개. +4. **‘삭제’ 중심의 운영 규칙**: 언제 넣고/빼고/격리할지 규칙(임계치, 주기, 스키마)과 로그 지표(토큰/비용/실패율) 추가. diff --git a/Context_Engineering_Research.ipynb b/Context_Engineering_Research.ipynb index 8760b28..8951e73 100644 --- a/Context_Engineering_Research.ipynb +++ b/Context_Engineering_Research.ipynb @@ -9,6 +9,27 @@ "\n", "DeepAgents 라이브러리에서 사용되는 5가지 Context Engineering 전략을 분석하고 실험합니다.\n", "\n", + "## 참고 자료\n", + "\n", + "- YouTube: https://www.youtube.com/watch?v=6_BcCthVvb8\n", + "- PDF: Context Engineering Meetup.pdf\n", + "- PDF: Manus Context Engineering LangChain Webinar.pdf\n", + "\n", + "## 문제 정의(왜 필요한가)\n", + "\n", + "- 에이전트는 도구 호출(tool calls)과 관찰(observations)이 누적되며 컨텍스트가 계속 성장합니다(수십~수백 턴).\n", + "- 컨텍스트가 길어질수록 성능이 떨어질 수 있다는 관측이 있습니다(context rot).\n", + "- 실패 모드: Poisoning / Distraction / Confusion / Clash\n", + "\n", + "## Manus 관점(모델 vs 앱 경계)\n", + "\n", + "- Context Engineering은 application과 model 사이의 실용적인 경계로 다뤄집니다.\n", + "- ‘모델을 따로 학습/미세조정’에 먼저 뛰어들기보다, 컨텍스트 설계로 제품 반복 속도를 확보한다는 관점이 강조됩니다.\n", + "\n", + "## 추가 주제: Tool Offloading\n", + "\n", + "- 도구 자체도 컨텍스트를 더럽힐 수 있으므로, 계층적 액션 스페이스/도구 로딩 제한(필요한 도구만 노출)을 고려합니다.\n", + "\n", "## Context Engineering 5가지 핵심 전략\n", "\n", "| 전략 | 설명 | DeepAgents 구현 |\n", @@ -396,7 +417,18 @@ "| 1. 기본 | ❌ | ❌ | ❌ | 베이스라인 |\n", "| 2. Offloading만 | ✅ | ❌ | ❌ | 대용량 결과 축출 효과 |\n", "| 3. Reduction만 | ❌ | ✅ | ❌ | 컨텍스트 압축 효과 |\n", - "| 4. 모두 활성화 | ✅ | ✅ | ✅ | 전체 효과 |" + "| 4. 모두 활성화 | ✅ | ✅ | ✅ | 전체 효과 |\n", + "\n", + "### 실패 모드(컨텍스트가 커질 때) 시뮬레이션 실험\n", + "\n", + "아래 실험 5~8은 **API 키 없이 실행 가능한 순수 파이썬 시뮬레이션**으로, “컨텍스트 실패 모드”를 재현하고 완화책을 보여줍니다.\n", + "\n", + "| 실험 | 실패 모드 | 무엇을 재현하나 | 완화책(예시) |\n", + "|------|----------|-----------------|--------------|\n", + "| 5 | Confusion | 도구가 많고 유사할수록 선택이 흔들림 | 도구 로딩 제한 / 계층적 액션 스페이스 |\n", + "| 6 | Clash | 연속된 관찰이 서로 모순될 때 혼란 | 충돌 감지 / 재검증 / 불확실성 표기 |\n", + "| 7 | Distraction | 긴 로그에서 반복 행동으로 쏠림 | 계획/목표 리프레시 / 강제 다음 행동 |\n", + "| 8 | Poisoning | 검증되지 않은 사실이 메모리를 오염 | 출처 태깅 / 검증 게이트 / 격리 |\n" ] }, { @@ -618,6 +650,1074 @@ "print(\"모든 에이전트가 성공적으로 생성되었습니다.\")" ] }, + { + "cell_type": "markdown", + "id": "exp5_8_real_intro", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 실험 5~8: 실패 모드 실험 (실제 실행 + 로그 기반)\n", + "\n", + "이 섹션은 앞선 “순수 파이썬 시뮬레이션”을 넘어서,\n", + "실제로 `langchain.agents.create_agent` + **Middleware**를 조합해 실행하면서\n", + "메시지/툴콜/툴결과 로그를 확인합니다.\n", + "\n", + "참고(공식 built-in middleware): https://docs.langchain.com/oss/python/langchain/middleware/built-in\n", + "\n", + "- Tool selection: `LLMToolSelectorMiddleware`\n", + "- Tool call limiting: `ToolCallLimitMiddleware`\n", + "\n", + "또한 deepagents에서 제공하는 **FilesystemMiddleware**(파일 툴 스택)를 함께 사용합니다.\n" + ] + }, + { + "cell_type": "code", + "id": "mw_real_helpers", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": [ + "from __future__ import annotations\n", + "\n", + "import json\n", + "import uuid\n", + "from collections.abc import Callable\n", + "from dataclasses import dataclass\n", + "from typing import Any\n", + "\n", + "from deepagents.backends import StateBackend\n", + "from deepagents.backends.utils import create_file_data\n", + "from deepagents.middleware.filesystem import FilesystemMiddleware\n", + "from langchain.agents import create_agent\n", + "from langchain.agents.middleware import LLMToolSelectorMiddleware, ToolCallLimitMiddleware\n", + "from langchain.agents.middleware.types import AgentMiddleware\n", + "from langchain_core.language_models import BaseChatModel\n", + "from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage, ToolMessage\n", + "from langchain_core.outputs import ChatGeneration, ChatResult\n", + "from langchain_core.runnables import RunnableLambda\n", + "from langchain_core.tools import tool\n", + "from langgraph.types import Overwrite\n", + "\n", + "\n", + "def _extract_valid_tool_names_from_schema(schema: dict[str, Any]) -> list[str]:\n", + " \"\"\"Extract tool-name enum from the JSON schema used by LLMToolSelectorMiddleware.\"\"\"\n", + " # The schema is produced from a Literal enum of tool names.\n", + " # We look for any 'enum' list nested in the schema.\n", + " enums: list[str] = []\n", + "\n", + " def walk(node: Any) -> None:\n", + " if isinstance(node, dict):\n", + " if 'enum' in node and isinstance(node['enum'], list):\n", + " for v in node['enum']:\n", + " if isinstance(v, str):\n", + " enums.append(v)\n", + " for v in node.values():\n", + " walk(v)\n", + " elif isinstance(node, list):\n", + " for v in node:\n", + " walk(v)\n", + "\n", + " walk(schema)\n", + " # Deduplicate while preserving order\n", + " seen: set[str] = set()\n", + " out: list[str] = []\n", + " for name in enums:\n", + " if name not in seen:\n", + " seen.add(name)\n", + " out.append(name)\n", + " return out\n", + "\n", + "\n", + "class DeterministicStructuredSelectorModel(BaseChatModel):\n", + " \"\"\"Offline tool-selection model compatible with `with_structured_output(schema)`.\n", + "\n", + " This is used to drive LangChain's `LLMToolSelectorMiddleware` without API keys.\n", + " \"\"\"\n", + "\n", + " def __init__(self, selector: Callable[[str, list[str]], list[str]]):\n", + " super().__init__()\n", + " self._selector = selector\n", + "\n", + " @property\n", + " def _llm_type(self) -> str:\n", + " return 'deterministic-structured-selector'\n", + "\n", + " @property\n", + " def _identifying_params(self) -> dict[str, Any]:\n", + " return {}\n", + "\n", + " def _generate(\n", + " self,\n", + " messages: list[BaseMessage],\n", + " stop: list[str] | None = None,\n", + " run_manager=None,\n", + " **kwargs: Any,\n", + " ) -> ChatResult:\n", + " # Not used by the tool selector middleware (it calls with_structured_output).\n", + " _ = (messages, stop, run_manager, kwargs)\n", + " return ChatResult(generations=[ChatGeneration(message=AIMessage(content='{}'))])\n", + "\n", + " def with_structured_output(self, schema: dict[str, Any], **kwargs: Any): # type: ignore[override]\n", + " _ = kwargs\n", + " valid = _extract_valid_tool_names_from_schema(schema)\n", + "\n", + " def _invoke(msgs: list[Any]) -> dict[str, Any]:\n", + " # msgs contains a system dict + last HumanMessage.\n", + " last_user = ''\n", + " for m in reversed(msgs):\n", + " if isinstance(m, HumanMessage):\n", + " last_user = m.content\n", + " break\n", + " selected = self._selector(last_user, valid)\n", + " return {'tools': selected}\n", + "\n", + " return RunnableLambda(_invoke)\n", + "\n", + "\n", + "class HeuristicToolCallingModel(BaseChatModel):\n", + " \"\"\"Offline tool-calling model that reacts to the *currently available tools*.\n", + "\n", + " This makes the effect of tool-selection middleware observable without external LLM calls.\n", + " \"\"\"\n", + "\n", + " def __init__(self, *, confusion_threshold: int = 10):\n", + " super().__init__()\n", + " self._bound_tool_names: list[str] = []\n", + " self._confusion_threshold = confusion_threshold\n", + "\n", + " @property\n", + " def _llm_type(self) -> str:\n", + " return 'heuristic-tool-calling'\n", + "\n", + " @property\n", + " def _identifying_params(self) -> dict[str, Any]:\n", + " return {'confusion_threshold': self._confusion_threshold}\n", + "\n", + " def bind_tools(self, tools: list[Any], **kwargs: Any): # noqa: ANN401\n", + " _ = kwargs\n", + " # Tools may include dict tool specs; filter those out.\n", + " self._bound_tool_names = [t.name for t in tools if hasattr(t, 'name')]\n", + " return self\n", + "\n", + " def _generate(\n", + " self,\n", + " messages: list[BaseMessage],\n", + " stop: list[str] | None = None,\n", + " run_manager=None,\n", + " **kwargs: Any,\n", + " ) -> ChatResult:\n", + " _ = (stop, run_manager, kwargs)\n", + "\n", + " # If the last message is a tool output, produce a final response.\n", + " if messages and isinstance(messages[-1], ToolMessage):\n", + " tool_msg = messages[-1]\n", + " return ChatResult(\n", + " generations=[\n", + " ChatGeneration(\n", + " message=AIMessage(\n", + " content=(\n", + " f\"[final] saw tool={tool_msg.name} status={tool_msg.status}\\n\"\n", + " f\"{tool_msg.content}\".strip()\n", + " )\n", + " )\n", + " )\n", + " ]\n", + " )\n", + "\n", + " # Find the last user message.\n", + " user_text = ''\n", + " for m in reversed(messages):\n", + " if isinstance(m, HumanMessage):\n", + " user_text = m.content\n", + " break\n", + "\n", + " tool_count = len(self._bound_tool_names)\n", + "\n", + " # Confusion heuristic: if too many tools, prefer (wrong) web_search.\n", + " if tool_count >= self._confusion_threshold and 'web_search' in self._bound_tool_names:\n", + " chosen = 'web_search'\n", + " args = {'query': user_text}\n", + " else:\n", + " # Prefer filesystem listing if available.\n", + " chosen = 'ls' if 'ls' in self._bound_tool_names else (self._bound_tool_names[0] if self._bound_tool_names else 'ls')\n", + " args = {'path': '/project'}\n", + "\n", + " tool_call_id = f\"call_{uuid.uuid4().hex[:8]}\"\n", + " msg = AIMessage(\n", + " content=f\"[debug] tool_count={tool_count} chosen={chosen}\",\n", + " tool_calls=[{'id': tool_call_id, 'name': chosen, 'args': args, 'type': 'tool_call'}],\n", + " )\n", + " return ChatResult(generations=[ChatGeneration(message=msg)])\n", + "\n", + "\n", + "def _print_messages(messages: list[BaseMessage]) -> None:\n", + " for i, m in enumerate(messages):\n", + " if isinstance(m, HumanMessage):\n", + " print(f\"{i:02d} HUMAN: {m.content}\")\n", + " elif isinstance(m, SystemMessage):\n", + " print(f\"{i:02d} SYSTEM: {m.content}\")\n", + " elif isinstance(m, AIMessage):\n", + " tool_calls = getattr(m, 'tool_calls', None) or []\n", + " print(f\"{i:02d} AI: {m.content}\")\n", + " for tc in tool_calls:\n", + " print(f\" tool_call: name={tc.get('name')} id={tc.get('id')} args={tc.get('args')}\")\n", + " elif isinstance(m, ToolMessage):\n", + " print(f\"{i:02d} TOOL: name={m.name} status={m.status} id={m.tool_call_id}\")\n", + " print(\" content:\", str(m.content)[:200])\n", + " else:\n", + " print(f\"{i:02d} {type(m).__name__}: {getattr(m, 'content', '')}\")\n", + "\n", + "\n", + "def _sample_files() -> dict[str, dict[str, Any]]:\n", + " return {\n", + " '/project/README.md': create_file_data(\"\"\"# Demo\n", + "This is a demo file.\"\"\"),\n", + " '/project/src/main.py': create_file_data(\"\"\"print(\\\"hello\\\")\n", + "\"\"\"),\n", + " '/project/src/utils.py': create_file_data(\"\"\"def add(a, b):\n", + " return a + b\n", + "\"\"\"),\n", + " }\n", + "\n", + "\n", + "\n", + "def _make_agent(*, model: BaseChatModel, tools: list[Any], middleware: list[AgentMiddleware]):\n", + " # Use StateBackend so FilesystemMiddleware can operate on in-memory `files`.\n", + " backend = lambda rt: StateBackend(rt) # noqa: E731\n", + " mw = [FilesystemMiddleware(backend=backend), *middleware]\n", + " return create_agent(model=model, tools=tools, middleware=mw)\n" + ] + }, + { + "cell_type": "markdown", + "id": "exp5_confusion", + "metadata": {}, + "source": [ + "### 실험 5: Context Confusion (도구 과다/유사 도구)\n", + "\n", + "도구가 많고 설명이 유사해질수록(특히 파일/검색류처럼 겹치는 기능이 많을수록) “올바른 도구 선택”이 흔들릴 수 있습니다.\n", + "\n", + "이 실험은 **도구 설명 기반의 단순 스코어링(lexical overlap)**으로 도구를 고르는 상황을 가정해:\n", + "\n", + "- 도구가 적을 때 vs 많을 때\n", + "- 유사한 도구가 많은 경우\n", + "\n", + "선택이 얼마나 불안정해지는지(상위 후보 점수 차이가 거의 없어지는지) 보여주고,\n", + "완화책으로 **도구 로딩 제한**과 **계층적 액션 스페이스(카테고리→도구)**를 시뮬레이션합니다.\n" + ] + }, + { + "cell_type": "code", + "id": "exp5_confusion_code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": [ + "from __future__ import annotations\n", + "\n", + "from dataclasses import dataclass\n", + "\n", + "\n", + "@dataclass(frozen=True)\n", + "class ToyTool:\n", + " name: str\n", + " description: str\n", + " category: str\n", + "\n", + "\n", + "def score_tool(query: str, tool: ToyTool) -> int:\n", + " q = set(query.lower().split())\n", + " d = set(tool.description.lower().split())\n", + " # 아주 단순한 overlap 점수(결정론적)\n", + " return len(q & d)\n", + "\n", + "\n", + "def rank_tools(query: str, tools: list[ToyTool]) -> list[tuple[int, ToyTool]]:\n", + " ranked = [(score_tool(query, t), t) for t in tools]\n", + " ranked.sort(key=lambda x: (x[0], x[1].name), reverse=True)\n", + " return ranked\n", + "\n", + "\n", + "def show_top(query: str, tools: list[ToyTool], top_k: int = 8) -> None:\n", + " ranked = rank_tools(query, tools)\n", + " print(f\"Query: {query}\")\n", + " print(\"Top candidates:\")\n", + " for score, tool in ranked[:top_k]:\n", + " print(f\" - {tool.name:18} score={score:2} ({tool.category})\")\n", + " top_scores = [s for s, _ in ranked[:top_k]]\n", + " gap = (top_scores[0] - top_scores[1]) if len(top_scores) > 1 else 0\n", + " print(f\"Top-1 vs Top-2 score gap: {gap}\")\n", + " print()\n", + "\n", + "\n", + "query = \"list files in directory and show file names\"\n", + "\n", + "small_toolset = [\n", + " ToyTool(\"ls\", \"list files in a directory\", \"filesystem\"),\n", + " ToyTool(\"read_file\", \"read a file from the filesystem\", \"filesystem\"),\n", + " ToyTool(\"web_search\", \"search the web for information\", \"web\"),\n", + "]\n", + "\n", + "large_similar_toolset = [\n", + " ToyTool(\"ls\", \"list files in a directory\", \"filesystem\"),\n", + " ToyTool(\"list_files\", \"list files in a directory and show file names\", \"filesystem\"),\n", + " ToyTool(\"list_dir\", \"list directory files and file names\", \"filesystem\"),\n", + " ToyTool(\"dir\", \"show directory listing and files\", \"filesystem\"),\n", + " ToyTool(\"glob\", \"find files matching a pattern\", \"filesystem\"),\n", + " ToyTool(\"grep\", \"search for a pattern in files\", \"filesystem\"),\n", + " ToyTool(\"read_file\", \"read a file from the filesystem\", \"filesystem\"),\n", + " ToyTool(\"cat\", \"print file content\", \"filesystem\"),\n", + " ToyTool(\"web_search\", \"search the web for information\", \"web\"),\n", + " ToyTool(\"fetch_url\", \"fetch a url and convert html to markdown\", \"web\"),\n", + "]\n", + "\n", + "print(\"=\" * 60)\n", + "print(\"[A] 도구가 적을 때\")\n", + "print(\"=\" * 60)\n", + "show_top(query, small_toolset)\n", + "\n", + "print(\"=\" * 60)\n", + "print(\"[B] 유사 도구가 많을 때(Confusion 유발)\")\n", + "print(\"=\" * 60)\n", + "show_top(query, large_similar_toolset)\n", + "\n", + "print(\"=\" * 60)\n", + "print(\"[C] 완화책 1: 도구 로딩 제한(카테고리 필터링)\")\n", + "print(\"=\" * 60)\n", + "filesystem_only = [t for t in large_similar_toolset if t.category == \"filesystem\"]\n", + "show_top(query, filesystem_only)\n", + "\n", + "print(\"=\" * 60)\n", + "print(\"[D] 완화책 2: 계층적 액션 스페이스(카테고리→도구)\")\n", + "print(\"=\" * 60)\n", + "chosen_category = \"filesystem\" if (\"file\" in query.lower() or \"directory\" in query.lower()) else \"web\"\n", + "print(f\"Chosen category: {chosen_category}\")\n", + "ranked = rank_tools(query, [t for t in large_similar_toolset if t.category == chosen_category])\n", + "print(f\"Chosen tool: {ranked[0][1].name}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "exp5_confusion_real_md", + "metadata": {}, + "source": [ + "#### (실행) LLMToolSelectorMiddleware로 도구 선택 제한 적용\n", + "\n", + "- Baseline: 도구가 너무 많아 `web_search`로 잘못 빠짐(Confusion)\n", + "- With tool selection: `LLMToolSelectorMiddleware(max_tools=5)`가 tool set을 줄여 `ls`로 유도\n" + ] + }, + { + "cell_type": "code", + "id": "exp5_confusion_real_code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": [ + "# 많은 더미 도구(유사/잡다한 도구)를 추가해 Confusion 상황을 만든다.\n", + "\n", + "@tool\n", + "def web_search(query: str) -> str:\n", + " \"\"\"Dummy web_search tool for the experiment.\"\"\"\n", + " return f\"(dummy) web_search results for query={query!r}\"\n", + "\n", + "\n", + "def _dummy_tool_factory(n: int):\n", + " @tool(f\"dummy_tool_{n}\", description=\"dummy tool\")\n", + " def _t(x: str = \"\") -> str:\n", + " return f\"dummy {n} {x}\".strip()\n", + "\n", + " return _t\n", + "\n", + "\n", + "dummy_tools = [_dummy_tool_factory(i) for i in range(25)]\n", + "all_tools = [web_search, *dummy_tools]\n", + "\n", + "# Selection model: choose filesystem tools when user asks about files/directories.\n", + "selector_model = DeterministicStructuredSelectorModel(\n", + " selector=lambda q, valid: [\n", + " name\n", + " for name in [\"ls\", \"read_file\", \"glob\", \"grep\"]\n", + " if name in valid\n", + " ]\n", + ")\n", + "\n", + "user = HumanMessage(content=\"/project 아래 파일 목록을 보여줘\")\n", + "state = {\"messages\": [user], \"files\": _sample_files()}\n", + "\n", + "print(\"=\" * 60)\n", + "print(\"[Baseline] tool selection 없음\")\n", + "print(\"=\" * 60)\n", + "agent_baseline = _make_agent(model=HeuristicToolCallingModel(confusion_threshold=10), tools=all_tools, middleware=[])\n", + "result_baseline = agent_baseline.invoke(state, {\"configurable\": {\"thread_id\": \"exp5_baseline\"}})\n", + "_print_messages(result_baseline[\"messages\"])\n", + "\n", + "print(\"\\n\" + \"=\" * 60)\n", + "print(\"[With LLMToolSelectorMiddleware] max_tools=5\")\n", + "print(\"=\" * 60)\n", + "agent_selected = _make_agent(\n", + " model=HeuristicToolCallingModel(confusion_threshold=10),\n", + " tools=all_tools,\n", + " middleware=[LLMToolSelectorMiddleware(model=selector_model, max_tools=5)],\n", + ")\n", + "result_selected = agent_selected.invoke(state, {\"configurable\": {\"thread_id\": \"exp5_selected\"}})\n", + "_print_messages(result_selected[\"messages\"])\n" + ] + }, + { + "cell_type": "markdown", + "id": "exp6_clash", + "metadata": {}, + "source": [ + "### 실험 6: Context Clash (모순되는 연속 관찰)\n", + "\n", + "연속된 도구 결과가 서로 모순될 때(예: 같은 키에 대해 다른 값), 모델은 어떤 값을 믿어야 할지 혼란스러워지고\n", + "이후 행동이 꼬일 수 있습니다.\n", + "\n", + "이 실험은:\n", + "\n", + "- 관찰을 상태(state)에 병합할 때 충돌을 감지\n", + "- “최신값 우선” 같은 임시 규칙 대신, **재검증/불확실성 표기**를 남기는 완화책\n", + "\n", + "을 시뮬레이션합니다.\n" + ] + }, + { + "cell_type": "code", + "id": "exp6_clash_code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": [ + "from __future__ import annotations\n", + "\n", + "\n", + "def merge_observation(state: dict[str, object], observation: dict[str, object], *, source: str):\n", + " # 관찰 병합 + 충돌 감지\n", + " conflicts: list[str] = []\n", + " new_state = dict(state)\n", + " for k, v in observation.items():\n", + " if k in new_state and new_state[k] != v:\n", + " conflicts.append(f\"{k}: '{new_state[k]}' vs '{v}' (source={source})\")\n", + " new_state[k] = v\n", + " return new_state, conflicts\n", + "\n", + "\n", + "state: dict[str, object] = {}\n", + "\n", + "obs1 = {\"latest_version\": \"1.2.0\", \"release_date\": \"2025-01-01\"}\n", + "obs2 = {\"latest_version\": \"1.3.0\", \"release_date\": \"2025-01-01\"} # version만 충돌\n", + "\n", + "print(\"=\" * 60)\n", + "print(\"[A] 충돌 없이 병합\")\n", + "print(\"=\" * 60)\n", + "state, c1 = merge_observation(state, obs1, source=\"tool_call_1\")\n", + "print(\"state:\", state)\n", + "print(\"conflicts:\", c1)\n", + "print()\n", + "\n", + "print(\"=\" * 60)\n", + "print(\"[B] 모순 관찰 입력(Clash)\")\n", + "print(\"=\" * 60)\n", + "state2, c2 = merge_observation(state, obs2, source=\"tool_call_2\")\n", + "print(\"state:\", state2)\n", + "print(\"conflicts:\")\n", + "for c in c2:\n", + " print(\" -\", c)\n", + "print()\n", + "\n", + "print(\"=\" * 60)\n", + "print(\"[C] 완화책: 충돌을 로그/검증 큐로 분리\")\n", + "print(\"=\" * 60)\n", + "final_state = dict(state)\n", + "conflict_log: list[str] = []\n", + "verification_queue: list[dict[str, object]] = []\n", + "\n", + "_, conflicts = merge_observation(final_state, obs2, source=\"tool_call_2\")\n", + "if conflicts:\n", + " conflict_log.extend(conflicts)\n", + " verification_queue.append({\"key\": \"latest_version\", \"candidates\": [\"1.2.0\", \"1.3.0\"]})\n", + "\n", + "print(\"conflict_log:\", conflict_log)\n", + "print(\"verification_queue:\", verification_queue)\n", + "\n", + "# (가정) 추가 검증 결과(tool_call_3)\n", + "verified = {\"latest_version\": \"1.3.0\"}\n", + "final_state, _ = merge_observation(final_state, verified, source=\"tool_call_3\")\n", + "print(\"verified final_state:\", final_state)\n" + ] + }, + { + "cell_type": "markdown", + "id": "exp6_clash_real_md", + "metadata": {}, + "source": [ + "#### (실행) 충돌 감지 미들웨어로 모순 관찰(Clash) 처리\n", + "\n", + "- Baseline: 두 소스가 서로 다른 값을 주면 “마지막 값”으로 덮어써 버림\n", + "- With clash detection: 충돌을 감지해 **verify tool 호출**을 유도\n" + ] + }, + { + "cell_type": "code", + "id": "exp6_clash_real_code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": [ + "from __future__ import annotations\n", + "\n", + "from langchain.agents.middleware.types import AgentState\n", + "from langgraph.runtime import Runtime\n", + "\n", + "\n", + "@tool(description=\"Get latest version from source A\")\n", + "def get_version_source_a() -> str:\n", + " return json.dumps({\"latest_version\": \"1.2.0\", \"source\": \"a\"})\n", + "\n", + "\n", + "@tool(description=\"Get latest version from source B\")\n", + "def get_version_source_b() -> str:\n", + " return json.dumps({\"latest_version\": \"1.3.0\", \"source\": \"b\"})\n", + "\n", + "\n", + "@tool(description=\"Verify latest version from an authoritative source\")\n", + "def verify_latest_version() -> str:\n", + " return json.dumps({\"latest_version\": \"1.3.0\", \"source\": \"verified\"})\n", + "\n", + "\n", + "class ClashDetectionMiddleware(AgentMiddleware):\n", + " \"\"\"Detect conflicting JSON facts in the last tool messages and request verification.\"\"\"\n", + "\n", + " def before_model(self, state: AgentState, runtime: Runtime[Any]) -> dict[str, Any] | None: # noqa: ARG002\n", + " messages = state.get(\"messages\", [])\n", + " # Do not trigger if already verified\n", + " for m in reversed(messages):\n", + " if isinstance(m, ToolMessage) and m.name == \"verify_latest_version\":\n", + " return None\n", + "\n", + " # Collect last two version tool messages\n", + " version_msgs: list[ToolMessage] = []\n", + " for m in reversed(messages):\n", + " if isinstance(m, ToolMessage) and m.name in {\"get_version_source_a\", \"get_version_source_b\"}:\n", + " version_msgs.append(m)\n", + " if len(version_msgs) >= 2:\n", + " break\n", + "\n", + " if len(version_msgs) < 2:\n", + " return None\n", + "\n", + " try:\n", + " a = json.loads(str(version_msgs[0].content))\n", + " b = json.loads(str(version_msgs[1].content))\n", + " except json.JSONDecodeError:\n", + " return None\n", + "\n", + " va = a.get(\"latest_version\")\n", + " vb = b.get(\"latest_version\")\n", + " if va and vb and va != vb:\n", + " patched = list(messages)\n", + " patched.append(\n", + " SystemMessage(\n", + " content=(\n", + " \"CONFLICT_DETECTED: latest_version has conflicting values. \"\n", + " \"Call verify_latest_version and use its result.\"\n", + " )\n", + " )\n", + " )\n", + " return {\"messages\": Overwrite(patched)}\n", + "\n", + " return None\n", + "\n", + "\n", + "class VersionResearchModel(BaseChatModel):\n", + " def bind_tools(self, tools: list[Any], **kwargs: Any): # noqa: ANN401\n", + " _ = kwargs\n", + " self._tool_names = [t.name for t in tools if hasattr(t, 'name')]\n", + " return self\n", + "\n", + " @property\n", + " def _llm_type(self) -> str:\n", + " return 'version-research'\n", + "\n", + " @property\n", + " def _identifying_params(self) -> dict[str, Any]:\n", + " return {}\n", + "\n", + " def _generate(self, messages: list[BaseMessage], stop=None, run_manager=None, **kwargs: Any) -> ChatResult:\n", + " _ = (stop, run_manager, kwargs)\n", + "\n", + " # Count tool results\n", + " have_a = any(isinstance(m, ToolMessage) and m.name == 'get_version_source_a' for m in messages)\n", + " have_b = any(isinstance(m, ToolMessage) and m.name == 'get_version_source_b' for m in messages)\n", + " have_v = any(isinstance(m, ToolMessage) and m.name == 'verify_latest_version' for m in messages)\n", + " conflict = any(isinstance(m, SystemMessage) and 'CONFLICT_DETECTED' in m.content for m in messages)\n", + "\n", + " if not have_a:\n", + " tcid = f\"call_{uuid.uuid4().hex[:8]}\"\n", + " msg = AIMessage(content='call source a', tool_calls=[{'id': tcid, 'name': 'get_version_source_a', 'args': {}, 'type': 'tool_call'}])\n", + " return ChatResult(generations=[ChatGeneration(message=msg)])\n", + "\n", + " if not have_b:\n", + " tcid = f\"call_{uuid.uuid4().hex[:8]}\"\n", + " msg = AIMessage(content='call source b', tool_calls=[{'id': tcid, 'name': 'get_version_source_b', 'args': {}, 'type': 'tool_call'}])\n", + " return ChatResult(generations=[ChatGeneration(message=msg)])\n", + "\n", + " if conflict and not have_v:\n", + " tcid = f\"call_{uuid.uuid4().hex[:8]}\"\n", + " msg = AIMessage(content='verify', tool_calls=[{'id': tcid, 'name': 'verify_latest_version', 'args': {}, 'type': 'tool_call'}])\n", + " return ChatResult(generations=[ChatGeneration(message=msg)])\n", + "\n", + " # Finalize: choose last seen latest_version\n", + " latest = None\n", + " for m in reversed(messages):\n", + " if isinstance(m, ToolMessage):\n", + " try:\n", + " data = json.loads(str(m.content))\n", + " except json.JSONDecodeError:\n", + " continue\n", + " if 'latest_version' in data:\n", + " latest = data['latest_version']\n", + " break\n", + " return ChatResult(generations=[ChatGeneration(message=AIMessage(content=f\"FINAL latest_version={latest}\"))])\n", + "\n", + "\n", + "user = HumanMessage(content=\"패키지 X의 최신 버전을 확인해줘\")\n", + "state = {\"messages\": [user]}\n", + "\n", + "tools = [get_version_source_a, get_version_source_b, verify_latest_version]\n", + "\n", + "print(\"=\" * 60)\n", + "print(\"[Baseline] clash detection 없음\")\n", + "print(\"=\" * 60)\n", + "agent_baseline = create_agent(model=VersionResearchModel(), tools=tools, middleware=[])\n", + "res1 = agent_baseline.invoke(state, {\"configurable\": {\"thread_id\": \"exp6_baseline\"}})\n", + "_print_messages(res1[\"messages\"])\n", + "\n", + "print(\"\\n\" + \"=\" * 60)\n", + "print(\"[With ClashDetectionMiddleware]\")\n", + "print(\"=\" * 60)\n", + "agent_clash = create_agent(model=VersionResearchModel(), tools=tools, middleware=[ClashDetectionMiddleware()])\n", + "res2 = agent_clash.invoke(state, {\"configurable\": {\"thread_id\": \"exp6_clash\"}})\n", + "_print_messages(res2[\"messages\"])\n" + ] + }, + { + "cell_type": "markdown", + "id": "exp7_distraction", + "metadata": {}, + "source": [ + "### 실험 7: Context Distraction (장기 로그에서 반복 행동 쏠림)\n", + "\n", + "긴 실행 기록이 쌓일수록, 모델이 “새 계획”보다 “이미 했던 행동”을 반복하는 쪽으로 쏠릴 수 있습니다.\n", + "\n", + "이 실험은 LLM을 직접 호출하지 않고, 단순화된 정책으로:\n", + "\n", + "- 로그가 길수록 과거 빈도 높은 행동을 더 강하게 재선택\n", + "\n", + "되는 현상을 시뮬레이션하고,\n", + "완화책으로 **명시적 계획(todo/next step)**를 “강제 입력”했을 때 분포가 다시 목표 중심으로 돌아오는 모습을 보여줍니다.\n" + ] + }, + { + "cell_type": "code", + "id": "exp7_distraction_code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": [ + "from __future__ import annotations\n", + "\n", + "import math\n", + "from collections import Counter\n", + "\n", + "\n", + "def softmax(xs: list[float]) -> list[float]:\n", + " m = max(xs)\n", + " exps = [math.exp(x - m) for x in xs]\n", + " s = sum(exps)\n", + " return [e / s for e in exps]\n", + "\n", + "\n", + "def entropy(ps: list[float]) -> float:\n", + " return -sum(p * math.log(p + 1e-12) for p in ps)\n", + "\n", + "\n", + "def action_distribution(actions: list[str], *, sharpness: float) -> dict[str, float]:\n", + " counts = Counter(actions)\n", + " keys = sorted(counts)\n", + " logits = [sharpness * math.log(counts[k]) for k in keys]\n", + " probs = softmax(logits)\n", + " return dict(zip(keys, probs, strict=True))\n", + "\n", + "\n", + "def show_dist(title: str, dist: dict[str, float]) -> None:\n", + " keys = sorted(dist, key=lambda k: dist[k], reverse=True)\n", + " ps = [dist[k] for k in keys]\n", + " print(title)\n", + " for k in keys[:6]:\n", + " print(f\" - {k:14} p={dist[k]:.3f}\")\n", + " print(f\" entropy={entropy(ps):.3f}\")\n", + " print()\n", + "\n", + "\n", + "# 과거 로그(반복 행동이 많은 상황)\n", + "actions = (\n", + " [\"web_search\"] * 40\n", + " + [\"read_file\"] * 20\n", + " + [\"ls\"] * 15\n", + " + [\"edit_file\"] * 5\n", + " + [\"write_todos\"] * 2\n", + ")\n", + "\n", + "print(\"=\" * 60)\n", + "print(\"[A] 짧은 컨텍스트(덜 쏠림)\")\n", + "print(\"=\" * 60)\n", + "short_ctx = actions[:20]\n", + "show_dist(\"short_ctx\", action_distribution(short_ctx, sharpness=1.0))\n", + "\n", + "print(\"=\" * 60)\n", + "print(\"[B] 긴 컨텍스트(더 쏠림 / 반복 행동 강화)\")\n", + "print(\"=\" * 60)\n", + "long_ctx = actions\n", + "show_dist(\"long_ctx\", action_distribution(long_ctx, sharpness=2.5))\n", + "\n", + "print(\"=\" * 60)\n", + "print(\"[C] 완화책: '다음 행동'을 계획으로 고정(강제 next step)\")\n", + "print(\"=\" * 60)\n", + "next_step = \"write_todos\" # 예: 계획 갱신을 강제\n", + "base = action_distribution(long_ctx, sharpness=2.5)\n", + "boost = 0.35\n", + "base[next_step] = base.get(next_step, 0.0) + boost\n", + "s = sum(base.values())\n", + "fixed = {k: v / s for k, v in base.items()}\n", + "show_dist(\"long_ctx + forced_next_step\", fixed)\n" + ] + }, + { + "cell_type": "markdown", + "id": "exp7_distraction_real_md", + "metadata": {}, + "source": [ + "#### (실행) ToolCallLimitMiddleware로 반복 행동(Distraction) 억제\n", + "\n", + "- Baseline: 같은 `web_search`를 반복 호출\n", + "- With `ToolCallLimitMiddleware(tool_name='web_search', run_limit=1)`: 2회차부터 차단되어 다른 행동으로 전환\n" + ] + }, + { + "cell_type": "code", + "id": "exp7_distraction_real_code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": [ + "from __future__ import annotations\n", + "\n", + "\n", + "@tool(description=\"Dummy web search tool\")\n", + "def web_search(query: str) -> str:\n", + " return f\"(dummy) result for {query!r}\"\n", + "\n", + "\n", + "@tool(description=\"Write a todo list\")\n", + "def write_todos(todos: list[str]) -> str:\n", + " return json.dumps({\"todos\": todos})\n", + "\n", + "\n", + "class LoopingSearchModel(BaseChatModel):\n", + " def bind_tools(self, tools: list[Any], **kwargs: Any): # noqa: ANN401\n", + " _ = kwargs\n", + " self._tool_names = [t.name for t in tools if hasattr(t, 'name')]\n", + " return self\n", + "\n", + " @property\n", + " def _llm_type(self) -> str:\n", + " return 'looping-search'\n", + "\n", + " @property\n", + " def _identifying_params(self) -> dict[str, Any]:\n", + " return {}\n", + "\n", + " def _generate(self, messages: list[BaseMessage], stop=None, run_manager=None, **kwargs: Any) -> ChatResult:\n", + " _ = (stop, run_manager, kwargs)\n", + "\n", + " # Count tool outcomes (robust stop conditions).\n", + " ok_search_results = [\n", + " m\n", + " for m in messages\n", + " if isinstance(m, ToolMessage) and m.name == 'web_search' and (m.status is None or m.status == 'success')\n", + " ]\n", + " error_search_results = [\n", + " m\n", + " for m in messages\n", + " if isinstance(m, ToolMessage) and m.name == 'web_search' and m.status == 'error'\n", + " ]\n", + " has_todo_result = any(isinstance(m, ToolMessage) and m.name == 'write_todos' for m in messages)\n", + "\n", + " # If we already wrote a todo list, end the run (avoid infinite tool-call loops).\n", + " if has_todo_result:\n", + " return ChatResult(generations=[ChatGeneration(message=AIMessage(content='FINAL todo list written'))])\n", + "\n", + " if len(ok_search_results) < 3 and not error_search_results:\n", + " tcid = f\"call_{uuid.uuid4().hex[:8]}\"\n", + " msg = AIMessage(\n", + " content=f\"search loop {len(ok_search_results)+1}\",\n", + " tool_calls=[{'id': tcid, 'name': 'web_search', 'args': {'query': 'context engineering'}, 'type': 'tool_call'}],\n", + " )\n", + " return ChatResult(generations=[ChatGeneration(message=msg)])\n", + "\n", + " # If blocked/error occurred (or we reached 3 searches), switch to planning once.\n", + " tcid = f\"call_{uuid.uuid4().hex[:8]}\"\n", + " msg = AIMessage(\n", + " content='switch to todos',\n", + " tool_calls=[{'id': tcid, 'name': 'write_todos', 'args': {'todos': ['summarize findings']}, 'type': 'tool_call'}],\n", + " )\n", + " return ChatResult(generations=[ChatGeneration(message=msg)])\n", + "\n", + "\n", + "user = HumanMessage(content=\"Context engineering을 조사해줘\")\n", + "state = {\"messages\": [user]}\n", + "\n", + "print(\"=\" * 60)\n", + "print(\"[Baseline] 제한 없음\")\n", + "print(\"=\" * 60)\n", + "agent_baseline = create_agent(model=LoopingSearchModel(), tools=[web_search, write_todos], middleware=[])\n", + "res1 = agent_baseline.invoke(state, {\"configurable\": {\"thread_id\": \"exp7_baseline\"}})\n", + "_print_messages(res1[\"messages\"])\n", + "\n", + "print(\"\\n\" + \"=\" * 60)\n", + "print(\"[With ToolCallLimitMiddleware] web_search run_limit=1\")\n", + "print(\"=\" * 60)\n", + "limiter = ToolCallLimitMiddleware(tool_name='web_search', run_limit=1, exit_behavior='continue')\n", + "agent_limited = create_agent(model=LoopingSearchModel(), tools=[web_search, write_todos], middleware=[limiter])\n", + "res2 = agent_limited.invoke(state, {\"configurable\": {\"thread_id\": \"exp7_limited\"}})\n", + "_print_messages(res2[\"messages\"])\n" + ] + }, + { + "cell_type": "markdown", + "id": "exp8_poisoning", + "metadata": {}, + "source": [ + "### 실험 8: Context Poisoning (검증되지 않은 사실의 오염)\n", + "\n", + "검증되지 않은 정보가 컨텍스트/메모리에 들어가면, 이후 의사결정이 그 “오염된 사실”을 기반으로 굳어질 수 있습니다.\n", + "\n", + "이 실험은:\n", + "\n", + "- 출처 없는 메모리 항목(검증되지 않음)이 이후 판단에 끼어드는 상황\n", + "- 완화책: **출처 태깅 + 검증 게이트(verified only)**\n", + "\n", + "을 시뮬레이션합니다.\n" + ] + }, + { + "cell_type": "code", + "id": "exp8_poisoning_code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": [ + "from __future__ import annotations\n", + "\n", + "from dataclasses import dataclass\n", + "\n", + "\n", + "@dataclass(frozen=True)\n", + "class MemoryItem:\n", + " key: str\n", + " value: str\n", + " source: str | None # tool_call_id 등\n", + " verified: bool\n", + "\n", + "\n", + "def choose_plan(memory: list[MemoryItem]) -> str:\n", + " # Toy planner: 메모리를 그대로 신뢰한다(나쁜 예)\n", + " installed = next((m.value for m in memory if m.key == \"package_installed\"), \"unknown\")\n", + " if installed == \"yes\":\n", + " return \"Skip install; proceed to use the package.\"\n", + " if installed == \"no\":\n", + " return \"Install the package.\"\n", + " return \"Check whether the package is installed.\"\n", + "\n", + "\n", + "def choose_plan_verified_only(memory: list[MemoryItem]) -> str:\n", + " verified = [m for m in memory if m.verified]\n", + " return choose_plan(verified)\n", + "\n", + "\n", + "memory_clean = [\n", + " MemoryItem(key=\"package_installed\", value=\"no\", source=\"tool_call_1\", verified=True),\n", + "]\n", + "\n", + "memory_poisoned = [\n", + " MemoryItem(key=\"package_installed\", value=\"no\", source=\"tool_call_1\", verified=True),\n", + " # 오염: 출처 없음 + 검증되지 않음\n", + " MemoryItem(key=\"package_installed\", value=\"yes\", source=None, verified=False),\n", + "]\n", + "\n", + "print(\"=\" * 60)\n", + "print(\"[A] 정상 메모리\")\n", + "print(\"=\" * 60)\n", + "print(\"blind plan:\", choose_plan(memory_clean))\n", + "print(\"verified-only plan:\", choose_plan_verified_only(memory_clean))\n", + "print()\n", + "\n", + "print(\"=\" * 60)\n", + "print(\"[B] 오염된 메모리(Poisoning)\")\n", + "print(\"=\" * 60)\n", + "print(\"blind plan:\", choose_plan(memory_poisoned))\n", + "print(\"verified-only plan:\", choose_plan_verified_only(memory_poisoned))\n", + "print()\n", + "\n", + "print(\"=\" * 60)\n", + "print(\"[C] 완화책: 출처 없는 사실은 검증 요청으로 라우팅\")\n", + "print(\"=\" * 60)\n", + "needs_verification = [m for m in memory_poisoned if (m.source is None or not m.verified)]\n", + "print(\"needs_verification:\")\n", + "for item in needs_verification:\n", + " print(f\" - {item.key}='{item.value}' source={item.source} verified={item.verified}\")\n", + "print(\"\\n→ 정책: tool로 재확인 후에만 state/memory에 반영\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "exp8_poisoning_real_md", + "metadata": {}, + "source": [ + "#### (실행) 검증 게이트(Verification Gate)로 Poisoning 차단\n", + "\n", + "- Baseline: `verified=false` 결과를 그대로 믿고 잘못된 계획을 수립\n", + "- With gate middleware: `verified=false` 사실은 차단하고, 검증된 tool로 재확인하도록 강제\n" + ] + }, + { + "cell_type": "code", + "id": "exp8_poisoning_real_code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": [ + "from __future__ import annotations\n", + "\n", + "from langchain.agents.middleware.types import AgentState\n", + "from langgraph.runtime import Runtime\n", + "\n", + "\n", + "@tool(description=\"Unverified guess of install status\")\n", + "def guess_install_status() -> str:\n", + " # Poisoned / unverified\n", + " return json.dumps({\"package_installed\": \"yes\", \"verified\": False, \"source\": \"guess\"})\n", + "\n", + "\n", + "@tool(description=\"Verified scan of install status\")\n", + "def scan_install_status() -> str:\n", + " # Verified\n", + " return json.dumps({\"package_installed\": \"no\", \"verified\": True, \"source\": \"scan\"})\n", + "\n", + "\n", + "class VerificationGateMiddleware(AgentMiddleware):\n", + " def before_model(self, state: AgentState, runtime: Runtime[Any]) -> dict[str, Any] | None: # noqa: ARG002\n", + " messages = state.get('messages', [])\n", + "\n", + " # Avoid repeatedly injecting the same constraint.\n", + " if any(isinstance(m, SystemMessage) and 'UNVERIFIED_FACT_BLOCKED' in m.content for m in messages):\n", + " return None\n", + " if any(isinstance(m, ToolMessage) and m.name == 'scan_install_status' for m in messages):\n", + " return None\n", + "\n", + " # If we see an unverified tool result, inject a system constraint.\n", + " for m in reversed(messages):\n", + " if isinstance(m, ToolMessage) and m.name == 'guess_install_status':\n", + " try:\n", + " data = json.loads(str(m.content))\n", + " except json.JSONDecodeError:\n", + " continue\n", + " if data.get('verified') is False:\n", + " patched = list(messages)\n", + " patched.append(\n", + " SystemMessage(\n", + " content=(\n", + " 'UNVERIFIED_FACT_BLOCKED: Do not trust guess_install_status. '\n", + " 'Call scan_install_status and decide based on verified=true only.'\n", + " )\n", + " )\n", + " )\n", + " return {'messages': Overwrite(patched)}\n", + " return None\n", + "\n", + "\n", + "class InstallPlannerModel(BaseChatModel):\n", + " def bind_tools(self, tools: list[Any], **kwargs: Any): # noqa: ANN401\n", + " _ = kwargs\n", + " self._tool_names = [t.name for t in tools if hasattr(t, 'name')]\n", + " return self\n", + "\n", + " @property\n", + " def _llm_type(self) -> str:\n", + " return 'install-planner'\n", + "\n", + " @property\n", + " def _identifying_params(self) -> dict[str, Any]:\n", + " return {}\n", + "\n", + " def _generate(self, messages: list[BaseMessage], stop=None, run_manager=None, **kwargs: Any) -> ChatResult:\n", + " _ = (stop, run_manager, kwargs)\n", + "\n", + " # If scan result exists, finalize.\n", + " for m in reversed(messages):\n", + " if isinstance(m, ToolMessage) and m.name == 'scan_install_status':\n", + " data = json.loads(str(m.content))\n", + " decision = 'INSTALL' if data.get('package_installed') == 'no' else 'SKIP'\n", + " return ChatResult(generations=[ChatGeneration(message=AIMessage(content=f\"FINAL decision={decision} (source=scan)\"))])\n", + "\n", + " blocked = any(\n", + " isinstance(m, SystemMessage) and 'UNVERIFIED_FACT_BLOCKED' in m.content for m in messages\n", + " )\n", + "\n", + " if blocked:\n", + " tcid = f\"call_{uuid.uuid4().hex[:8]}\"\n", + " msg = AIMessage(content='scan', tool_calls=[{'id': tcid, 'name': 'scan_install_status', 'args': {}, 'type': 'tool_call'}])\n", + " return ChatResult(generations=[ChatGeneration(message=msg)])\n", + "\n", + " # Baseline behavior: trust guess first.\n", + " if not any(isinstance(m, ToolMessage) and m.name == 'guess_install_status' for m in messages):\n", + " tcid = f\"call_{uuid.uuid4().hex[:8]}\"\n", + " msg = AIMessage(content='guess', tool_calls=[{'id': tcid, 'name': 'guess_install_status', 'args': {}, 'type': 'tool_call'}])\n", + " return ChatResult(generations=[ChatGeneration(message=msg)])\n", + "\n", + " # If guess exists and no gate, finalize (poisoned).\n", + " for m in reversed(messages):\n", + " if isinstance(m, ToolMessage) and m.name == 'guess_install_status':\n", + " data = json.loads(str(m.content))\n", + " decision = 'SKIP' if data.get('package_installed') == 'yes' else 'INSTALL'\n", + " return ChatResult(generations=[ChatGeneration(message=AIMessage(content=f\"FINAL decision={decision} (source=guess)\"))])\n", + "\n", + " return ChatResult(generations=[ChatGeneration(message=AIMessage(content='FINAL no decision'))])\n", + "\n", + "\n", + "user = HumanMessage(content='패키지 X 설치가 필요한지 판단해줘')\n", + "state = {\"messages\": [user]}\n", + "\n", + "tools = [guess_install_status, scan_install_status]\n", + "\n", + "print(\"=\" * 60)\n", + "print(\"[Baseline] verification gate 없음\")\n", + "print(\"=\" * 60)\n", + "agent_baseline = create_agent(model=InstallPlannerModel(), tools=tools, middleware=[])\n", + "res1 = agent_baseline.invoke(state, {\"configurable\": {\"thread_id\": \"exp8_baseline\"}})\n", + "_print_messages(res1['messages'])\n", + "\n", + "print(\"\\n\" + \"=\" * 60)\n", + "print(\"[With VerificationGateMiddleware]\")\n", + "print(\"=\" * 60)\n", + "agent_gated = create_agent(model=InstallPlannerModel(), tools=tools, middleware=[VerificationGateMiddleware()])\n", + "res2 = agent_gated.invoke(state, {\"configurable\": {\"thread_id\": \"exp8_gated\"}})\n", + "_print_messages(res2['messages'])\n" + ] + }, { "cell_type": "markdown", "id": "exp5_recommendation", @@ -657,7 +1757,23 @@ "1. **파일시스템 = 외부 메모리**: 컨텍스트 윈도우는 제한되어 있지만, 파일시스템은 무한\n", "2. **점진적 공개**: 모든 정보를 한 번에 로드하지 않고 필요할 때만 로드\n", "3. **격리된 실행**: SubAgent로 컨텍스트 오염 방지\n", - "4. **자동화된 관리**: 에이전트가 직접 컨텍스트를 관리하도록 미들웨어 설계" + "4. **자동화된 관리**: 에이전트가 직접 컨텍스트를 관리하도록 미들웨어 설계\n", + "5. **실패 모드 방어**: Poisoning/Distraction/Confusion/Clash를 관측하고 완화하는 규칙이 필요\n", + "6. **도구도 컨텍스트다**: 도구 설명/목록도 최소화하고, 필요할 때만 로딩(또는 계층화)\n", + "\n", + "### 추가 실험(실패 모드)\n", + "\n", + "- **Confusion**: 유사 도구가 많을수록 선택이 불안정해짐(도구 로딩 제한/계층화로 완화)\n", + "- **Clash**: 모순 관찰을 충돌로 기록하고 재검증으로 해소\n", + "- **Distraction**: 장기 로그에서 반복 행동 쏠림(계획/다음 행동 강제로 완화)\n", + "- **Poisoning**: 출처 없는 사실을 차단하고 검증 게이트로 통제\n", + "\n", + "### 추가 실험(실패 모드) - 실제 실행 기반\n", + "\n", + "- **Tool Selection**: `LLMToolSelectorMiddleware`로 tool set을 축소해 Confusion 완화\n", + "- **Tool Call Limiting**: `ToolCallLimitMiddleware`로 반복 tool call을 차단해 Distraction 완화\n", + "- **Filesystem Tools**: deepagents `FilesystemMiddleware`로 `ls/read_file/glob/grep` 실제 실행 로그 확인\n", + "- **Custom Guards**: (실험 목적) 충돌 감지/검증 게이트를 `AgentMiddleware`로 구현해 Clash/Poisoning 완화\n" ] } ], diff --git a/deepagents_sourcecode/examples/ralph_mode/ralph_mode.py b/deepagents_sourcecode/examples/ralph_mode/ralph_mode.py index 3bfbb30..56cc1bd 100644 --- a/deepagents_sourcecode/examples/ralph_mode/ralph_mode.py +++ b/deepagents_sourcecode/examples/ralph_mode/ralph_mode.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -""" -Ralph Mode - Autonomous looping for DeepAgents +"""Ralph Mode - DeepAgents를 위한 자율 루프 실행 예제입니다. Ralph is an autonomous looping pattern created by Geoff Huntley. Each loop starts with fresh context. The filesystem and git serve as memory. @@ -10,24 +9,24 @@ Usage: python ralph_mode.py "Build a Python course. Use git." python ralph_mode.py "Build a REST API" --iterations 5 """ -import warnings -warnings.filterwarnings("ignore", message="Core Pydantic V1 functionality") - import argparse import asyncio import tempfile +import warnings from pathlib import Path from deepagents_cli.agent import create_cli_agent -from deepagents_cli.config import console, COLORS, SessionState, create_model +from deepagents_cli.config import COLORS, SessionState, console, create_model from deepagents_cli.execution import execute_task from deepagents_cli.ui import TokenTracker +warnings.filterwarnings("ignore", message="Core Pydantic V1 functionality") -async def ralph(task: str, max_iterations: int = 0, model_name: str = None): - """Run agent in Ralph loop with beautiful CLI output.""" + +async def ralph(task: str, max_iterations: int = 0, model_name: str | None = None) -> None: + """Ralph 루프 패턴으로 에이전트를 반복 실행합니다.""" work_dir = tempfile.mkdtemp(prefix="ralph-") - + model = create_model(model_name) agent, backend = create_cli_agent( model=model, @@ -37,19 +36,21 @@ async def ralph(task: str, max_iterations: int = 0, model_name: str = None): ) session_state = SessionState(auto_approve=True) token_tracker = TokenTracker() - + console.print(f"\n[bold {COLORS['primary']}]Ralph Mode[/bold {COLORS['primary']}]") console.print(f"[dim]Task: {task}[/dim]") - console.print(f"[dim]Iterations: {'unlimited (Ctrl+C to stop)' if max_iterations == 0 else max_iterations}[/dim]") + console.print( + f"[dim]Iterations: {'unlimited (Ctrl+C to stop)' if max_iterations == 0 else max_iterations}[/dim]" + ) console.print(f"[dim]Working directory: {work_dir}[/dim]\n") - + iteration = 1 try: while max_iterations == 0 or iteration <= max_iterations: console.print(f"\n[bold cyan]{'='*60}[/bold cyan]") console.print(f"[bold cyan]RALPH ITERATION {iteration}[/bold cyan]") console.print(f"[bold cyan]{'='*60}[/bold cyan]\n") - + iter_display = f"{iteration}/{max_iterations}" if max_iterations > 0 else str(iteration) prompt = f"""## Iteration {iter_display} @@ -68,13 +69,13 @@ Make progress. You'll be called again.""" token_tracker, backend=backend, ) - + console.print(f"\n[dim]...continuing to iteration {iteration + 1}[/dim]") iteration += 1 - + except KeyboardInterrupt: console.print(f"\n[bold yellow]Stopped after {iteration} iterations[/bold yellow]") - + # Show created files console.print(f"\n[bold]Files created in {work_dir}:[/bold]") for f in sorted(Path(work_dir).rglob("*")): @@ -82,7 +83,7 @@ Make progress. You'll be called again.""" console.print(f" {f.relative_to(work_dir)}", style="dim") -def main(): +def main() -> None: parser = argparse.ArgumentParser( description="Ralph Mode - Autonomous looping for DeepAgents", formatter_class=argparse.RawDescriptionHelpFormatter, diff --git a/deepagents_sourcecode/libs/acp/deepagents_acp/__init__.py b/deepagents_sourcecode/libs/acp/deepagents_acp/__init__.py index e69de29..322fee1 100644 --- a/deepagents_sourcecode/libs/acp/deepagents_acp/__init__.py +++ b/deepagents_sourcecode/libs/acp/deepagents_acp/__init__.py @@ -0,0 +1 @@ +"""DeepAgents ACP 패키지입니다.""" diff --git a/deepagents_sourcecode/libs/acp/deepagents_acp/server.py b/deepagents_sourcecode/libs/acp/deepagents_acp/server.py index 21ced5c..57ca656 100644 --- a/deepagents_sourcecode/libs/acp/deepagents_acp/server.py +++ b/deepagents_sourcecode/libs/acp/deepagents_acp/server.py @@ -1,4 +1,4 @@ -"""DeepAgents ACP server implementation.""" +"""DeepAgents ACP 서버 구현입니다.""" from __future__ import annotations @@ -7,38 +7,40 @@ import uuid from typing import Any, Literal from acp import ( + PROTOCOL_VERSION, Agent, AgentSideConnection, - PROTOCOL_VERSION, stdio_streams, ) from acp.schema import ( AgentMessageChunk, + AgentPlanUpdate, + AgentThoughtChunk, + AllowedOutcome, + CancelNotification, + ContentToolCallContent, + DeniedOutcome, + Implementation, InitializeRequest, InitializeResponse, + LoadSessionRequest, + LoadSessionResponse, NewSessionRequest, NewSessionResponse, + PermissionOption, + PlanEntry, PromptRequest, PromptResponse, - SessionNotification, - TextContentBlock, - Implementation, - AgentThoughtChunk, - ToolCallProgress, - ContentToolCallContent, - LoadSessionResponse, - SetSessionModeResponse, - SetSessionModelResponse, - CancelNotification, - LoadSessionRequest, - SetSessionModeRequest, - SetSessionModelRequest, - AgentPlanUpdate, - PlanEntry, - PermissionOption, RequestPermissionRequest, - AllowedOutcome, - DeniedOutcome, + SessionNotification, + SetSessionModelRequest, + SetSessionModelResponse, + SetSessionModeRequest, + SetSessionModeResponse, + TextContentBlock, + ToolCallProgress, +) +from acp.schema import ( ToolCall as ACPToolCall, ) from deepagents import create_deep_agent @@ -52,14 +54,14 @@ from langgraph.types import Command, Interrupt class DeepagentsACP(Agent): - """ACP Agent implementation wrapping deepagents.""" + """deepagents를 감싼 ACP Agent 구현체입니다.""" def __init__( self, connection: AgentSideConnection, agent_graph: CompiledStateGraph, ) -> None: - """Initialize the DeepAgents agent. + """DeepAgents 에이전트를 초기화합니다. Args: connection: The ACP connection for communicating with the client @@ -68,15 +70,15 @@ class DeepagentsACP(Agent): self._connection = connection self._agent_graph = agent_graph self._sessions: dict[str, dict[str, Any]] = {} - # Track tool calls by ID for matching with ToolMessages - # Maps tool_call_id -> ToolCall TypedDict + # ToolMessage와 매칭하기 위해 tool_call_id별 tool call을 추적합니다. + # tool_call_id -> ToolCall TypedDict self._tool_calls: dict[str, ToolCall] = {} async def initialize( self, params: InitializeRequest, ) -> InitializeResponse: - """Initialize the agent and return capabilities.""" + """에이전트를 초기화하고 capabilities를 반환합니다.""" return InitializeResponse( protocolVersion=PROTOCOL_VERSION, agentInfo=Implementation( @@ -90,7 +92,7 @@ class DeepagentsACP(Agent): self, params: NewSessionRequest, ) -> NewSessionResponse: - """Create a new session with a deepagents instance.""" + """DeepAgents 인스턴스로 새 세션을 생성합니다.""" session_id = str(uuid.uuid4()) # Store session state with the shared agent graph self._sessions[session_id] = { @@ -105,7 +107,7 @@ class DeepagentsACP(Agent): params: PromptRequest, message: AIMessageChunk, ) -> None: - """Handle an AIMessageChunk and send appropriate notifications. + """AIMessageChunk를 처리하고 적절한 알림(notification)을 전송합니다. Args: params: The prompt request parameters @@ -159,7 +161,7 @@ class DeepagentsACP(Agent): params: PromptRequest, message: AIMessage, ) -> None: - """Handle completed tool calls from an AIMessage and send notifications. + """AIMessage의 완료된 tool call을 처리하고 알림(notification)을 전송합니다. Args: params: The prompt request parameters @@ -214,7 +216,7 @@ class DeepagentsACP(Agent): tool_call: ToolCall, message: ToolMessage, ) -> None: - """Handle a ToolMessage and send appropriate notifications. + """ToolMessage를 처리하고 적절한 알림(notification)을 전송합니다. Args: params: The prompt request parameters @@ -264,7 +266,7 @@ class DeepagentsACP(Agent): params: PromptRequest, todos: list[dict[str, Any]], ) -> None: - """Handle todo list updates from the tools node. + """Tools node에서 발생한 todo list 업데이트를 처리합니다. Args: params: The prompt request parameters @@ -309,7 +311,7 @@ class DeepagentsACP(Agent): params: PromptRequest, interrupt: Interrupt, ) -> list[dict[str, Any]]: - """Handle a LangGraph interrupt and request permission from the client. + """LangGraph interrupt를 처리하고 클라이언트에 권한을 요청합니다. Args: params: The prompt request parameters @@ -423,7 +425,7 @@ class DeepagentsACP(Agent): stream_input: dict[str, Any] | Command, config: dict[str, Any], ) -> list[Interrupt]: - """Stream agent execution and handle updates, returning any interrupts. + """에이전트 실행을 스트리밍하고 업데이트를 처리하며, interrupt가 있으면 반환합니다. Args: params: The prompt request parameters @@ -496,7 +498,7 @@ class DeepagentsACP(Agent): self, params: PromptRequest, ) -> PromptResponse: - """Handle a user prompt and stream responses.""" + """사용자 프롬프트를 처리하고 응답을 스트리밍합니다.""" session_id = params.sessionId session = self._sessions.get(session_id) @@ -541,50 +543,50 @@ class DeepagentsACP(Agent): return PromptResponse(stopReason="end_turn") async def authenticate(self, params: Any) -> Any | None: - """Authenticate (optional).""" - # Authentication not required for now + """인증 처리(선택).""" + # 현재는 인증이 필요하지 않습니다. return None async def extMethod(self, method: str, params: dict[str, Any]) -> dict[str, Any]: - """Handle extension methods (optional).""" + """Extension method 처리(선택).""" raise NotImplementedError(f"Extension method {method} not supported") async def extNotification(self, method: str, params: dict[str, Any]) -> None: - """Handle extension notifications (optional).""" + """Extension notification 처리(선택).""" pass async def cancel(self, params: CancelNotification) -> None: - """Cancel a running session.""" - # TODO: Implement cancellation logic + """실행 중인 세션을 취소합니다.""" + # TODO: 취소 로직 구현 pass async def loadSession( self, params: LoadSessionRequest, ) -> LoadSessionResponse | None: - """Load an existing session (optional).""" - # Not implemented yet - would need to serialize/deserialize session state + """기존 세션 로드(선택).""" + # 미구현: 세션 state의 serialize/deserialize가 필요합니다. return None async def setSessionMode( self, params: SetSessionModeRequest, ) -> SetSessionModeResponse | None: - """Set session mode (optional).""" - # Could be used to switch between different agent modes + """세션 모드 설정(선택).""" + # 다른 agent mode로 전환하는 용도로 사용할 수 있습니다. return None async def setSessionModel( self, params: SetSessionModelRequest, ) -> SetSessionModelResponse | None: - """Set session model (optional).""" - # Not supported - model is configured at agent graph creation time + """세션 모델 설정(선택).""" + # 미지원: 모델은 agent graph 생성 시점에 고정됩니다. return None async def main() -> None: - """Main entry point for running the ACP server.""" + """ACP 서버 실행용 메인 엔트리포인트입니다.""" # from deepagents_cli.agent import create_agent_with_config # from deepagents_cli.config import create_model # from deepagents_cli.tools import fetch_url, http_request, web_search @@ -647,7 +649,7 @@ async def main() -> None: def cli_main() -> None: - """Synchronous CLI entry point for the ACP server.""" + """ACP 서버 실행용 동기 CLI 엔트리포인트입니다.""" asyncio.run(main()) diff --git a/deepagents_sourcecode/libs/acp/tests/__init__.py b/deepagents_sourcecode/libs/acp/tests/__init__.py index e69de29..38765c6 100644 --- a/deepagents_sourcecode/libs/acp/tests/__init__.py +++ b/deepagents_sourcecode/libs/acp/tests/__init__.py @@ -0,0 +1 @@ +"""deepagents_acp 테스트 패키지입니다.""" diff --git a/deepagents_sourcecode/libs/acp/tests/chat_model.py b/deepagents_sourcecode/libs/acp/tests/chat_model.py index 402790a..8ca2c6f 100644 --- a/deepagents_sourcecode/libs/acp/tests/chat_model.py +++ b/deepagents_sourcecode/libs/acp/tests/chat_model.py @@ -1,10 +1,8 @@ -"""Fake chat models for testing purposes.""" +"""테스트용 가짜(chat) 모델 구현입니다.""" import re from collections.abc import Callable, Iterator, Sequence -from typing import Any, Literal, cast - -from typing_extensions import override +from typing import Any, cast from langchain_core.callbacks import CallbackManagerForLLMRun from langchain_core.language_models import LanguageModelInput @@ -13,10 +11,11 @@ from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult from langchain_core.runnables import Runnable from langchain_core.tools import BaseTool +from typing_extensions import override class GenericFakeChatModel(BaseChatModel): - """Generic fake chat model that can be used to test the chat model interface. + r"""Generic fake chat model that can be used to test the chat model interface. * Chat model should be usable in both sync and async tests * Invokes `on_llm_new_token` to allow for testing of callback related code for new @@ -29,7 +28,7 @@ class GenericFakeChatModel(BaseChatModel): - None (default): Return content in a single chunk (no streaming) - A string delimiter (e.g., " "): Split content on this delimiter, preserving the delimiter as separate chunks - - A regex pattern (e.g., r"(\\s)"): Split using the pattern with a capture + - A regex pattern (e.g., r"(\s)"): Split using the pattern with a capture group to preserve delimiters Examples: @@ -52,7 +51,7 @@ class GenericFakeChatModel(BaseChatModel): """ messages: Iterator[AIMessage | str] - """Get an iterator over messages. + """메시지 이터레이터를 가져옵니다. This can be expanded to accept other types like Callables / dicts / strings to make the interface more generic if needed. @@ -62,7 +61,7 @@ class GenericFakeChatModel(BaseChatModel): """ stream_delimiter: str | None = None - """Delimiter for chunking content during streaming. + """스트리밍 시 content를 chunk로 나누기 위한 delimiter입니다. - None (default): No chunking, returns content in a single chunk - String: Split content on this exact string, preserving delimiter as chunks @@ -227,5 +226,5 @@ class GenericFakeChatModel(BaseChatModel): tool_choice: str | None = None, **kwargs: Any, ) -> Runnable[LanguageModelInput, AIMessage]: - """Override bind_tools to return self for testing purposes.""" + """테스트 목적상 `bind_tools`를 오버라이드하여 자기 자신을 반환합니다.""" return self diff --git a/deepagents_sourcecode/libs/acp/tests/test_server.py b/deepagents_sourcecode/libs/acp/tests/test_server.py index 3a1f994..90312c8 100644 --- a/deepagents_sourcecode/libs/acp/tests/test_server.py +++ b/deepagents_sourcecode/libs/acp/tests/test_server.py @@ -1,19 +1,22 @@ +"""deepagents_acp 서버 동작을 검증하는 테스트들입니다.""" + from contextlib import asynccontextmanager from typing import Any -from acp.schema import NewSessionRequest, PromptRequest from acp.schema import ( - TextContentBlock, + AllowedOutcome, + NewSessionRequest, + PromptRequest, RequestPermissionRequest, RequestPermissionResponse, - AllowedOutcome, + TextContentBlock, ) +from deepagents_acp.server import DeepagentsACP from dirty_equals import IsUUID from langchain_core.messages import AIMessage, BaseMessage from langchain_core.tools import tool from langgraph.checkpoint.memory import InMemorySaver -from deepagents_acp.server import DeepagentsACP from tests.chat_model import GenericFakeChatModel @@ -68,13 +71,13 @@ async def deepagents_acp_test_context( stream_delimiter: str | None = r"(\s)", middleware: list[Any] | None = None, ): - """Context manager for testing DeepagentsACP. + r"""Context manager for testing DeepagentsACP. Args: messages: List of messages for the fake model to return prompt_request: The prompt request to send to the agent tools: List of tools to provide to the agent (defaults to []) - stream_delimiter: How to chunk content when streaming (default: r"(\\s)" for whitespace) + stream_delimiter: How to chunk content when streaming (default: r"(\s)" for whitespace) middleware: Optional middleware to add to the agent graph Yields: @@ -422,8 +425,8 @@ async def test_fake_chat_model_streaming() -> None: async def test_human_in_the_loop_approval() -> None: """Test that DeepagentsACP handles HITL interrupts and permission requests correctly.""" - from langchain.agents.middleware import HumanInTheLoopMiddleware from deepagents.graph import create_deep_agent + from langchain.agents.middleware import HumanInTheLoopMiddleware prompt_request = PromptRequest( sessionId="", # Will be set below diff --git a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/__init__.py b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/__init__.py index 064b099..bd49518 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/__init__.py +++ b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/__init__.py @@ -1,4 +1,4 @@ -"""DeepAgents CLI - Interactive AI coding assistant.""" +"""DeepAgents CLI - 대화형 AI 코딩 어시스턴트입니다.""" from deepagents_cli.main import cli_main diff --git a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/__main__.py b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/__main__.py index 9f12f51..6634a97 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/__main__.py +++ b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/__main__.py @@ -1,4 +1,7 @@ -"""Allow running the CLI as: python -m deepagents.cli.""" +"""`python -m deepagents.cli` 형태로 CLI를 실행할 수 있게 하는 엔트리포인트입니다. + +Allow running the CLI as: python -m deepagents.cli. +""" from deepagents_cli.main import cli_main diff --git a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/_version.py b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/_version.py index ea58fa0..6d898bd 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/_version.py +++ b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/_version.py @@ -1,3 +1,6 @@ -"""Version information for deepagents-cli.""" +"""deepagents-cli 버전 정보를 제공합니다. + +Version information for deepagents-cli. +""" __version__ = "0.0.12" diff --git a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/agent.py b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/agent.py index 40b7357..4dbff3c 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/agent.py +++ b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/agent.py @@ -1,4 +1,6 @@ -"""Agent management and creation for the CLI.""" +"""deepagents-cli에서 에이전트를 생성하고 관리하는 로직입니다.""" + +# ruff: noqa: E501 import os import shutil @@ -25,9 +27,11 @@ from deepagents_cli.config import COLORS, config, console, get_default_coding_in from deepagents_cli.integrations.sandbox_factory import get_default_working_dir from deepagents_cli.shell import ShellMiddleware +DESCRIPTION_PREVIEW_LIMIT = 500 + def list_agents() -> None: - """List all available agents.""" + """사용 가능한 모든 에이전트를 나열합니다.""" agents_dir = settings.user_deepagents_dir if not agents_dir.exists() or not any(agents_dir.iterdir()): @@ -58,7 +62,7 @@ def list_agents() -> None: def reset_agent(agent_name: str, source_agent: str | None = None) -> None: - """Reset an agent to default or copy from another agent.""" + """에이전트를 기본값으로 리셋하거나 다른 에이전트 설정을 복사합니다.""" agents_dir = settings.user_deepagents_dir agent_dir = agents_dir / agent_name @@ -92,7 +96,7 @@ def reset_agent(agent_name: str, source_agent: str | None = None) -> None: def get_system_prompt(assistant_id: str, sandbox_type: str | None = None) -> str: - """Get the base system prompt for the agent. + """에이전트의 기본 system prompt를 생성합니다. Args: assistant_id: The agent identifier for path references @@ -190,7 +194,7 @@ The todo list is a planning tool - use it judiciously to avoid overwhelming the def _format_write_file_description( tool_call: ToolCall, _state: AgentState, _runtime: Runtime ) -> str: - """Format write_file tool call for approval prompt.""" + """승인 프롬프트에 표시할 `write_file` 도구 호출 설명을 포맷팅합니다.""" args = tool_call["args"] file_path = args.get("file_path", "unknown") content = args.get("content", "") @@ -204,7 +208,7 @@ def _format_write_file_description( def _format_edit_file_description( tool_call: ToolCall, _state: AgentState, _runtime: Runtime ) -> str: - """Format edit_file tool call for approval prompt.""" + """승인 프롬프트에 표시할 `edit_file` 도구 호출 설명을 포맷팅합니다.""" args = tool_call["args"] file_path = args.get("file_path", "unknown") replace_all = bool(args.get("replace_all", False)) @@ -218,7 +222,7 @@ def _format_edit_file_description( def _format_web_search_description( tool_call: ToolCall, _state: AgentState, _runtime: Runtime ) -> str: - """Format web_search tool call for approval prompt.""" + """승인 프롬프트에 표시할 `web_search` 도구 호출 설명을 포맷팅합니다.""" args = tool_call["args"] query = args.get("query", "unknown") max_results = args.get("max_results", 5) @@ -229,7 +233,7 @@ def _format_web_search_description( def _format_fetch_url_description( tool_call: ToolCall, _state: AgentState, _runtime: Runtime ) -> str: - """Format fetch_url tool call for approval prompt.""" + """승인 프롬프트에 표시할 `fetch_url` 도구 호출 설명을 포맷팅합니다.""" args = tool_call["args"] url = args.get("url", "unknown") timeout = args.get("timeout", 30) @@ -238,7 +242,7 @@ def _format_fetch_url_description( def _format_task_description(tool_call: ToolCall, _state: AgentState, _runtime: Runtime) -> str: - """Format task (subagent) tool call for approval prompt. + """승인 프롬프트에 표시할 `task`(서브에이전트) 도구 호출 설명을 포맷팅합니다. The task tool signature is: task(description: str, subagent_type: str) The description contains all instructions that will be sent to the subagent. @@ -249,8 +253,8 @@ def _format_task_description(tool_call: ToolCall, _state: AgentState, _runtime: # Truncate description if too long for display description_preview = description - if len(description) > 500: - description_preview = description[:500] + "..." + if len(description) > DESCRIPTION_PREVIEW_LIMIT: + description_preview = description[:DESCRIPTION_PREVIEW_LIMIT] + "..." return ( f"Subagent Type: {subagent_type}\n\n" @@ -263,21 +267,21 @@ def _format_task_description(tool_call: ToolCall, _state: AgentState, _runtime: def _format_shell_description(tool_call: ToolCall, _state: AgentState, _runtime: Runtime) -> str: - """Format shell tool call for approval prompt.""" + """승인 프롬프트에 표시할 `shell` 도구 호출 설명을 포맷팅합니다.""" args = tool_call["args"] command = args.get("command", "N/A") return f"Shell Command: {command}\nWorking Directory: {Path.cwd()}" def _format_execute_description(tool_call: ToolCall, _state: AgentState, _runtime: Runtime) -> str: - """Format execute tool call for approval prompt.""" + """승인 프롬프트에 표시할 `execute` 도구 호출 설명을 포맷팅합니다.""" args = tool_call["args"] command = args.get("command", "N/A") return f"Execute Command: {command}\nLocation: Remote Sandbox" def _add_interrupt_on() -> dict[str, InterruptOnConfig]: - """Configure human-in-the-loop interrupt_on settings for destructive tools.""" + """파괴적인 도구에 대한 HITL(Human-In-The-Loop) interrupt_on 설정을 구성합니다.""" shell_interrupt_config: InterruptOnConfig = { "allowed_decisions": ["approve", "reject"], "description": _format_shell_description, @@ -337,7 +341,7 @@ def create_cli_agent( enable_shell: bool = True, checkpointer: BaseCheckpointSaver | None = None, ) -> tuple[Pregel, CompositeBackend]: - """Create a CLI-configured agent with flexible options. + """옵션을 유연하게 조합할 수 있는 CLI용 에이전트를 생성합니다. This is the main entry point for creating a deepagents CLI agent, usable both internally and from external code (e.g., benchmarking frameworks, Harbor). @@ -442,12 +446,7 @@ def create_cli_agent( system_prompt = get_system_prompt(assistant_id=assistant_id, sandbox_type=sandbox_type) # Configure interrupt_on based on auto_approve setting - if auto_approve: - # No interrupts - all tools run automatically - interrupt_on = {} - else: - # Full HITL for destructive operations - interrupt_on = _add_interrupt_on() + interrupt_on = {} if auto_approve else _add_interrupt_on() composite_backend = CompositeBackend( default=backend, diff --git a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/app.py b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/app.py index 0956782..7d46ec3 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/app.py +++ b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/app.py @@ -1,9 +1,13 @@ -"""Textual UI application for deepagents-cli.""" +"""deepagents-cli의 Textual UI 애플리케이션입니다. + +Textual UI application for deepagents-cli. +""" from __future__ import annotations import asyncio import contextlib +import logging import subprocess import uuid from pathlib import Path @@ -31,6 +35,10 @@ from deepagents_cli.widgets.messages import ( from deepagents_cli.widgets.status import StatusBar from deepagents_cli.widgets.welcome import WelcomeBanner +logger = logging.getLogger(__name__) + +TOKENS_K_THRESHOLD = 1000 + if TYPE_CHECKING: from langgraph.pregel import Pregel from textual.app import ComposeResult @@ -382,41 +390,49 @@ class DeepAgentsApp(App): """ cmd = command.lower().strip() - if cmd in ("/quit", "/exit", "/q"): + if cmd in {"/quit", "/exit", "/q"}: self.exit() - elif cmd == "/help": - await self._mount_message(UserMessage(command)) + return + + await self._mount_message(UserMessage(command)) + + if cmd == "/help": await self._mount_message( SystemMessage("Commands: /quit, /clear, /tokens, /threads, /help") ) - elif cmd == "/clear": + return + + if cmd == "/clear": await self._clear_messages() # Reset thread to start fresh conversation if self._session_state: new_thread_id = self._session_state.reset_thread() await self._mount_message(SystemMessage(f"Started new session: {new_thread_id}")) - elif cmd == "/threads": - await self._mount_message(UserMessage(command)) + return + + if cmd == "/threads": if self._session_state: await self._mount_message( SystemMessage(f"Current session: {self._session_state.thread_id}") ) else: await self._mount_message(SystemMessage("No active session")) - elif cmd == "/tokens": - await self._mount_message(UserMessage(command)) + return + + if cmd == "/tokens": if self._token_tracker and self._token_tracker.current_context > 0: count = self._token_tracker.current_context - if count >= 1000: - formatted = f"{count / 1000:.1f}K" - else: - formatted = str(count) + formatted = ( + f"{count / TOKENS_K_THRESHOLD:.1f}K" + if count >= TOKENS_K_THRESHOLD + else str(count) + ) await self._mount_message(SystemMessage(f"Current context: {formatted} tokens")) else: await self._mount_message(SystemMessage("No token usage yet")) - else: - await self._mount_message(UserMessage(command)) - await self._mount_message(SystemMessage(f"Unknown command: {cmd}")) + return + + await self._mount_message(SystemMessage(f"Unknown command: {cmd}")) async def _handle_user_message(self, message: str) -> None: """Handle a user message to send to the agent. @@ -572,8 +588,8 @@ class DeepAgentsApp(App): if tool_msg.has_output: tool_msg.toggle_output() return - except Exception: - pass + except Exception as err: # noqa: BLE001 + logger.debug("Failed to toggle tool output.", exc_info=err) # Approval menu action handlers (delegated from App-level bindings) # NOTE: These only activate when approval widget is pending AND input is not focused diff --git a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/clipboard.py b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/clipboard.py index 68e546c..daf03db 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/clipboard.py +++ b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/clipboard.py @@ -1,14 +1,21 @@ -"""Clipboard utilities for deepagents-cli.""" +"""deepagents-cli에서 클립보드 연동을 위한 유틸리티입니다. + +Clipboard utilities for deepagents-cli. +""" from __future__ import annotations import base64 +import logging import os +from pathlib import Path from typing import TYPE_CHECKING if TYPE_CHECKING: from textual.app import App +logger = logging.getLogger(__name__) + _PREVIEW_MAX_LENGTH = 40 @@ -19,7 +26,7 @@ def _copy_osc52(text: str) -> None: if os.environ.get("TMUX"): osc52_seq = f"\033Ptmux;\033{osc52_seq}\033\\" - with open("/dev/tty", "w") as tty: + with Path("/dev/tty").open("w") as tty: tty.write(osc52_seq) tty.flush() @@ -48,7 +55,8 @@ def copy_selection_to_clipboard(app: App) -> None: try: result = widget.get_selection(selection) - except Exception: + except (AttributeError, ValueError, TypeError) as err: + logger.debug("Failed to read selection from widget.", exc_info=err) continue if not result: @@ -77,14 +85,16 @@ def copy_selection_to_clipboard(app: App) -> None: for copy_fn in copy_methods: try: copy_fn(combined_text) + except (OSError, RuntimeError, ValueError) as err: + logger.debug("Clipboard copy method failed.", exc_info=err) + continue + else: app.notify( f'"{_shorten_preview(selected_texts)}" copied', severity="information", timeout=2, ) return - except Exception: - continue # If all methods fail, still notify but warn app.notify( diff --git a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/config.py b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/config.py index c38b908..f86ac29 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/config.py +++ b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/config.py @@ -1,4 +1,7 @@ -"""Configuration, constants, and model creation for the CLI.""" +"""CLI 설정/상수/모델 생성 관련 로직입니다. + +Configuration, constants, and model creation for the CLI. +""" import os import re @@ -24,7 +27,7 @@ if _deepagents_project: os.environ["LANGSMITH_PROJECT"] = _deepagents_project # Now safe to import LangChain modules -from langchain_core.language_models import BaseChatModel +from langchain_core.language_models import BaseChatModel # noqa: E402 # Color scheme COLORS = { @@ -115,12 +118,12 @@ def _find_project_agent_md(project_root: Path) -> list[Path]: """ paths = [] - # Check .deepagents/AGENTS.md (preferred) + # Prefer the `.deepagents/AGENTS.md` location when present. deepagents_md = project_root / ".deepagents" / "AGENTS.md" if deepagents_md.exists(): paths.append(deepagents_md) - # Check root AGENTS.md (fallback, but also include if both exist) + # Also look for a repository-root `AGENTS.md` (and include both if both exist). root_md = project_root / "AGENTS.md" if root_md.exists(): paths.append(root_md) @@ -377,7 +380,13 @@ settings = Settings.from_environment() class SessionState: """Holds mutable session state (auto-approve mode, etc).""" - def __init__(self, auto_approve: bool = False, no_splash: bool = False) -> None: + def __init__(self, *, auto_approve: bool = False, no_splash: bool = False) -> None: + """세션 상태를 초기화합니다. + + Args: + auto_approve: 도구 호출을 자동 승인할지 여부 + no_splash: 시작 시 스플래시 화면을 숨길지 여부 + """ self.auto_approve = auto_approve self.no_splash = no_splash self.exit_hint_until: float | None = None @@ -439,7 +448,8 @@ def create_model(model_name_override: str | None = None) -> BaseChatModel: provider = _detect_provider(model_name_override) if not provider: console.print( - f"[bold red]Error:[/bold red] Could not detect provider from model name: {model_name_override}" + "[bold red]Error:[/bold red] Could not detect provider from model name: " + f"{model_name_override}" ) console.print("\nSupported model name patterns:") console.print(" - OpenAI: gpt-*, o1-*, o3-*") @@ -450,17 +460,20 @@ def create_model(model_name_override: str | None = None) -> BaseChatModel: # Check if API key for detected provider is available if provider == "openai" and not settings.has_openai: console.print( - f"[bold red]Error:[/bold red] Model '{model_name_override}' requires OPENAI_API_KEY" + "[bold red]Error:[/bold red] Model " + f"'{model_name_override}' requires OPENAI_API_KEY" ) sys.exit(1) elif provider == "anthropic" and not settings.has_anthropic: console.print( - f"[bold red]Error:[/bold red] Model '{model_name_override}' requires ANTHROPIC_API_KEY" + "[bold red]Error:[/bold red] Model " + f"'{model_name_override}' requires ANTHROPIC_API_KEY" ) sys.exit(1) elif provider == "google" and not settings.has_google: console.print( - f"[bold red]Error:[/bold red] Model '{model_name_override}' requires GOOGLE_API_KEY" + "[bold red]Error:[/bold red] Model " + f"'{model_name_override}' requires GOOGLE_API_KEY" ) sys.exit(1) @@ -510,3 +523,6 @@ def create_model(model_name_override: str | None = None) -> BaseChatModel: temperature=0, max_tokens=None, ) + + msg = f"Unsupported model provider: {provider}" + raise RuntimeError(msg) diff --git a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/file_ops.py b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/file_ops.py index 09250d9..c43839f 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/file_ops.py +++ b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/file_ops.py @@ -1,8 +1,12 @@ -"""Helpers for tracking file operations and computing diffs for CLI display.""" +"""CLI 표시를 위해 파일 작업을 추적하고 diff를 계산하는 유틸리티입니다. + +Helpers for tracking file operations and computing diffs for CLI display. +""" from __future__ import annotations import difflib +import logging from dataclasses import dataclass, field from pathlib import Path from typing import TYPE_CHECKING, Any, Literal @@ -13,9 +17,12 @@ from deepagents_cli.config import settings if TYPE_CHECKING: from deepagents.backends.protocol import BACKEND_TYPES + from langchain_core.messages import ToolMessage FileOpStatus = Literal["pending", "success", "error"] +logger = logging.getLogger(__name__) + @dataclass class ApprovalPreview: @@ -249,6 +256,7 @@ class FileOpTracker: def start_operation( self, tool_name: str, args: dict[str, Any], tool_call_id: str | None ) -> None: + """파일 도구 호출을 추적하기 위한 operation을 시작합니다.""" if tool_name not in {"read_file", "write_file", "edit_file"}: return path_str = str(args.get("file_path") or args.get("path") or "") @@ -272,7 +280,8 @@ class FileOpTracker: record.before_content = responses[0].content.decode("utf-8") else: record.before_content = "" - except Exception: + except Exception as err: # noqa: BLE001 + logger.debug("Failed to download file content before operation.", exc_info=err) record.before_content = "" elif record.physical_path: record.before_content = _safe_read(record.physical_path) or "" @@ -303,12 +312,19 @@ class FileOpTracker: record.before_content = responses[0].content.decode("utf-8") else: record.before_content = "" - except Exception: + except Exception as err: # noqa: BLE001 + logger.debug( + "Failed to download file content before operation.", + exc_info=err, + ) record.before_content = "" elif record.physical_path: record.before_content = _safe_read(record.physical_path) or "" - def complete_with_message(self, tool_message: Any) -> FileOperationRecord | None: + def complete_with_message( # noqa: PLR0912, PLR0915 + self, tool_message: ToolMessage + ) -> FileOperationRecord | None: + """도구 실행 결과(ToolMessage)를 사용해 operation을 완료 처리합니다.""" tool_call_id = getattr(tool_message, "tool_call_id", None) record = self.active.get(tool_call_id) if record is None: @@ -430,7 +446,8 @@ class FileOpTracker: record.after_content = None else: record.after_content = None - except Exception: + except Exception as err: # noqa: BLE001 + logger.debug("Failed to download file content after operation.", exc_info=err) record.after_content = None else: # Fallback: direct filesystem read when no backend provided diff --git a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/image_utils.py b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/image_utils.py index be1fc67..a2a712c 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/image_utils.py +++ b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/image_utils.py @@ -1,14 +1,19 @@ -"""Utilities for handling image paste from clipboard.""" +"""클립보드에서 이미지 붙여넣기(paste)를 처리하는 유틸리티입니다. + +Utilities for handling image paste from clipboard. +""" import base64 +import contextlib import io -import os +import shutil import subprocess import sys import tempfile from dataclasses import dataclass +from pathlib import Path -from PIL import Image +from PIL import Image, UnidentifiedImageError @dataclass @@ -54,33 +59,36 @@ def _get_macos_clipboard_image() -> ImageData | None: ImageData if an image is found, None otherwise """ # Try pngpaste first (fast if installed) - try: - result = subprocess.run( - ["pngpaste", "-"], - capture_output=True, - check=False, - timeout=2, - ) - if result.returncode == 0 and result.stdout: - # Successfully got PNG data - try: - Image.open(io.BytesIO(result.stdout)) # Validate it's a real image - base64_data = base64.b64encode(result.stdout).decode("utf-8") - return ImageData( - base64_data=base64_data, - format="png", # 'pngpaste -' always outputs PNG - placeholder="[image]", - ) - except Exception: - pass # Invalid image data - except (FileNotFoundError, subprocess.TimeoutExpired): - pass # pngpaste not installed or timed out + pngpaste_path = shutil.which("pngpaste") + if pngpaste_path: + try: + result = subprocess.run( # noqa: S603 + [pngpaste_path, "-"], + capture_output=True, + check=False, + timeout=2, + ) + if result.returncode == 0 and result.stdout: + # Successfully got PNG data + try: + Image.open(io.BytesIO(result.stdout)) # Validate it's a real image + except (UnidentifiedImageError, OSError): + pass # Invalid image data + else: + base64_data = base64.b64encode(result.stdout).decode("utf-8") + return ImageData( + base64_data=base64_data, + format="png", # 'pngpaste -' always outputs PNG + placeholder="[image]", + ) + except subprocess.TimeoutExpired: + pass # pngpaste timed out # Fallback to osascript with temp file (built-in but slower) return _get_clipboard_via_osascript() -def _get_clipboard_via_osascript() -> ImageData | None: +def _get_clipboard_via_osascript() -> ImageData | None: # noqa: PLR0911 """Get clipboard image via osascript using a temp file. osascript outputs data in a special format that can't be captured as raw binary, @@ -89,14 +97,18 @@ def _get_clipboard_via_osascript() -> ImageData | None: Returns: ImageData if an image is found, None otherwise """ + osascript_path = shutil.which("osascript") + if not osascript_path: + return None + # Create a temp file for the image - fd, temp_path = tempfile.mkstemp(suffix=".png") - os.close(fd) + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp: + temp_file = Path(tmp.name) try: # First check if clipboard has PNG data - check_result = subprocess.run( - ["osascript", "-e", "clipboard info"], + check_result = subprocess.run( # noqa: S603 + [osascript_path, "-e", "clipboard info"], capture_output=True, check=False, timeout=2, @@ -115,7 +127,7 @@ def _get_clipboard_via_osascript() -> ImageData | None: if "pngf" in clipboard_info: get_script = f""" set pngData to the clipboard as «class PNGf» - set theFile to open for access POSIX file "{temp_path}" with write permission + set theFile to open for access POSIX file "{temp_file.as_posix()}" with write permission write pngData to theFile close access theFile return "success" @@ -123,14 +135,14 @@ def _get_clipboard_via_osascript() -> ImageData | None: else: get_script = f""" set tiffData to the clipboard as TIFF picture - set theFile to open for access POSIX file "{temp_path}" with write permission + set theFile to open for access POSIX file "{temp_file.as_posix()}" with write permission write tiffData to theFile close access theFile return "success" """ - result = subprocess.run( - ["osascript", "-e", get_script], + result = subprocess.run( # noqa: S603 + [osascript_path, "-e", get_script], capture_output=True, check=False, timeout=3, @@ -141,12 +153,11 @@ def _get_clipboard_via_osascript() -> ImageData | None: return None # Check if file was created and has content - if not os.path.exists(temp_path) or os.path.getsize(temp_path) == 0: + if not temp_file.exists() or temp_file.stat().st_size == 0: return None # Read and validate the image - with open(temp_path, "rb") as f: - image_data = f.read() + image_data = temp_file.read_bytes() try: image = Image.open(io.BytesIO(image_data)) @@ -161,17 +172,15 @@ def _get_clipboard_via_osascript() -> ImageData | None: format="png", placeholder="[image]", ) - except Exception: + except (UnidentifiedImageError, OSError): return None except (subprocess.TimeoutExpired, OSError): return None finally: # Clean up temp file - try: - os.unlink(temp_path) - except OSError: - pass + with contextlib.suppress(OSError): + temp_file.unlink() def encode_image_to_base64(image_bytes: bytes) -> str: @@ -203,7 +212,6 @@ def create_multimodal_content(text: str, images: list[ImageData]) -> list[dict]: content_blocks.append({"type": "text", "text": text}) # Add image blocks - for image in images: - content_blocks.append(image.to_message_content()) + content_blocks.extend([image.to_message_content() for image in images]) return content_blocks diff --git a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/input.py b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/input.py index 4f0062b..e66b706 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/input.py +++ b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/input.py @@ -1,11 +1,16 @@ -"""Input handling, completers, and prompt session for the CLI.""" +"""CLI 입력 처리(완성/프롬프트 세션 포함)를 담당합니다. + +Input handling, completers, and prompt session for the CLI. +""" + +from __future__ import annotations import asyncio import os import re import time -from collections.abc import Callable from pathlib import Path +from typing import TYPE_CHECKING from prompt_toolkit import PromptSession from prompt_toolkit.completion import ( @@ -22,6 +27,12 @@ from prompt_toolkit.key_binding import KeyBindings from .config import COLORS, COMMANDS, SessionState, console from .image_utils import ImageData, get_clipboard_image +if TYPE_CHECKING: + from collections.abc import Callable, Iterator + + from prompt_toolkit.completion.base import CompleteEvent + from prompt_toolkit.key_binding.key_processor import KeyPressEvent + # Regex patterns for context-aware completion AT_MENTION_RE = re.compile(r"@(?P(?:[^\s@]|(?<=\\)\s)*)$") SLASH_COMMAND_RE = re.compile(r"^/(?P[a-z]*)$") @@ -33,6 +44,7 @@ class ImageTracker: """Track pasted images in the current conversation.""" def __init__(self) -> None: + """이미지 트래커를 초기화합니다.""" self.images: list[ImageData] = [] self.next_id = 1 @@ -65,13 +77,16 @@ class FilePathCompleter(Completer): """Activate filesystem completion only when cursor is after '@'.""" def __init__(self) -> None: + """파일 경로 자동완성 컴플리터를 초기화합니다.""" self.path_completer = PathCompleter( expanduser=True, min_input_len=0, only_directories=False, ) - def get_completions(self, document, complete_event): + def get_completions( + self, document: Document, complete_event: CompleteEvent + ) -> Iterator[Completion]: """Get file path completions when @ is detected.""" text = document.text_before_cursor @@ -112,7 +127,9 @@ class FilePathCompleter(Completer): class CommandCompleter(Completer): """Activate command completion only when line starts with '/'.""" - def get_completions(self, document, _complete_event): + def get_completions( + self, document: Document, _complete_event: CompleteEvent + ) -> Iterator[Completion]: """Get command completions when / is at the start.""" text = document.text_before_cursor @@ -155,8 +172,8 @@ def parse_file_mentions(text: str) -> tuple[str, list[Path]]: files.append(path) else: console.print(f"[yellow]Warning: File not found: {match}[/yellow]") - except Exception as e: - console.print(f"[yellow]Warning: Invalid path {match}: {e}[/yellow]") + except OSError as err: + console.print(f"[yellow]Warning: Invalid path {match}: {err}[/yellow]") return text, files @@ -221,7 +238,7 @@ def get_bottom_toolbar( return toolbar -def create_prompt_session( +def create_prompt_session( # noqa: PLR0915 _assistant_id: str, session_state: SessionState, image_tracker: ImageTracker | None = None ) -> PromptSession: """Create a configured PromptSession with all features.""" @@ -233,7 +250,7 @@ def create_prompt_session( kb = KeyBindings() @kb.add("c-c") - def _(event) -> None: + def _(event: KeyPressEvent) -> None: """Require double Ctrl+C within a short window to exit.""" app = event.app now = time.monotonic() @@ -272,7 +289,7 @@ def create_prompt_session( # Bind Ctrl+T to toggle auto-approve @kb.add("c-t") - def _(event) -> None: + def _(event: KeyPressEvent) -> None: """Toggle auto-approve mode.""" session_state.toggle_auto_approve() # Force UI refresh to update toolbar @@ -282,7 +299,9 @@ def create_prompt_session( if image_tracker: from prompt_toolkit.keys import Keys - def _handle_paste_with_image_check(event, pasted_text: str = "") -> None: + def _handle_paste_with_image_check( + event: KeyPressEvent, pasted_text: str = "" + ) -> None: """Check clipboard for image, otherwise insert pasted text.""" # Try to get an image from clipboard clipboard_image = get_clipboard_image() @@ -302,20 +321,20 @@ def create_prompt_session( event.current_buffer.insert_text(clipboard_data.text) @kb.add(Keys.BracketedPaste) - def _(event) -> None: + def _(event: KeyPressEvent) -> None: """Handle bracketed paste (Cmd+V on macOS) - check for images first.""" # Bracketed paste provides the pasted text in event.data pasted_text = event.data if hasattr(event, "data") else "" _handle_paste_with_image_check(event, pasted_text) @kb.add("c-v") - def _(event) -> None: + def _(event: KeyPressEvent) -> None: """Handle Ctrl+V paste - check for images first.""" _handle_paste_with_image_check(event) # Bind regular Enter to submit (intuitive behavior) @kb.add("enter") - def _(event) -> None: + def _(event: KeyPressEvent) -> None: """Enter submits the input, unless completion menu is active.""" buffer = event.current_buffer @@ -344,19 +363,19 @@ def create_prompt_session( # Alt+Enter for newlines (press ESC then Enter, or Option+Enter on Mac) @kb.add("escape", "enter") - def _(event) -> None: + def _(event: KeyPressEvent) -> None: """Alt+Enter inserts a newline for multi-line input.""" event.current_buffer.insert_text("\n") # Ctrl+E to open in external editor @kb.add("c-e") - def _(event) -> None: + def _(event: KeyPressEvent) -> None: """Open the current input in an external editor (nano by default).""" event.current_buffer.open_in_editor() # Backspace handler to retrigger completions and delete image tags as units @kb.add("backspace") - def _(event) -> None: + def _(event: KeyPressEvent) -> None: """Handle backspace: delete image tags as single unit, retrigger completion.""" buffer = event.current_buffer text_before = buffer.document.text_before_cursor diff --git a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/integrations/__init__.py b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/integrations/__init__.py index c91693d..ce1d918 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/integrations/__init__.py +++ b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/integrations/__init__.py @@ -1 +1,4 @@ -"""Sandbox integrations for DeepAgents CLI.""" +"""DeepAgents CLI의 샌드박스(integrations) 모듈입니다. + +Sandbox integrations for DeepAgents CLI. +""" diff --git a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/integrations/daytona.py b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/integrations/daytona.py index 6a07b17..ed9b6c5 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/integrations/daytona.py +++ b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/integrations/daytona.py @@ -1,4 +1,7 @@ -"""Daytona sandbox backend implementation.""" +"""Daytona 샌드박스 백엔드 구현입니다. + +Daytona sandbox backend implementation. +""" from __future__ import annotations @@ -70,8 +73,9 @@ class DaytonaBackend(BaseSandbox): List of FileDownloadResponse objects, one per input path. Response order matches input order. - TODO: Map Daytona API error strings to standardized FileOperationError codes. - Currently only implements happy path. + Note: Daytona API 에러 문자열을 표준화된 FileOperationError 코드로 매핑하는 작업은 + 추후 보완합니다. + 현재는 정상(happy path) 위주로만 구현되어 있습니다. """ from daytona import FileDownloadRequest @@ -80,12 +84,12 @@ class DaytonaBackend(BaseSandbox): daytona_responses = self._sandbox.fs.download_files(download_requests) # Convert Daytona results to our response format - # TODO: Map resp.error to standardized error codes when available + # NOTE: resp.error를 표준화된 error code로 매핑하는 작업은 추후 보완합니다. return [ FileDownloadResponse( path=resp.source, content=resp.result, - error=None, # TODO: map resp.error to FileOperationError + error=None, # NOTE: resp.error -> FileOperationError 매핑은 추후 보완 ) for resp in daytona_responses ] @@ -104,14 +108,18 @@ class DaytonaBackend(BaseSandbox): List of FileUploadResponse objects, one per input file. Response order matches input order. - TODO: Map Daytona API error strings to standardized FileOperationError codes. - Currently only implements happy path. + Note: Daytona API 에러 문자열을 표준화된 FileOperationError 코드로 매핑하는 작업은 + 추후 보완합니다. + 현재는 정상(happy path) 위주로만 구현되어 있습니다. """ from daytona import FileUpload # Create batch upload request using Daytona's native batch API - upload_requests = [FileUpload(source=content, destination=path) for path, content in files] + upload_requests = [ + FileUpload(source=content, destination=path) for path, content in files + ] self._sandbox.fs.upload_files(upload_requests) - # TODO: Check if Daytona returns error info and map to FileOperationError codes + # NOTE: Daytona가 error 정보를 제공하는 경우, FileOperationError 코드로 매핑하는 작업은 + # 추후 보완합니다. return [FileUploadResponse(path=path, error=None) for path, _ in files] diff --git a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/integrations/modal.py b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/integrations/modal.py index 18bb7f3..1f0dc7a 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/integrations/modal.py +++ b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/integrations/modal.py @@ -1,4 +1,7 @@ -"""Modal sandbox backend implementation.""" +"""Modal 샌드박스 백엔드 구현입니다. + +Modal sandbox backend implementation. +""" from __future__ import annotations diff --git a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/integrations/runloop.py b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/integrations/runloop.py index 794027f..2c7f2f1 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/integrations/runloop.py +++ b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/integrations/runloop.py @@ -1,19 +1,21 @@ -"""BackendProtocol implementation for Runloop.""" +"""Runloop용 `BackendProtocol` 구현입니다. + +BackendProtocol implementation for Runloop. +""" try: - import runloop_api_client + from runloop_api_client import Runloop except ImportError: msg = ( "runloop_api_client package is required for RunloopBackend. " "Install with `pip install runloop_api_client`." ) - raise ImportError(msg) + raise ImportError(msg) from None import os from deepagents.backends.protocol import ExecuteResponse, FileDownloadResponse, FileUploadResponse from deepagents.backends.sandbox import BaseSandbox -from runloop_api_client import Runloop class RunloopBackend(BaseSandbox): diff --git a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/integrations/sandbox_factory.py b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/integrations/sandbox_factory.py index cdf6234..a3f76fa 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/integrations/sandbox_factory.py +++ b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/integrations/sandbox_factory.py @@ -1,5 +1,9 @@ -"""Sandbox lifecycle management with context managers.""" +"""컨텍스트 매니저 기반 샌드박스 라이프사이클 관리 유틸리티입니다. +Sandbox lifecycle management with context managers. +""" + +import logging import os import shlex import string @@ -12,6 +16,8 @@ from deepagents.backends.protocol import SandboxBackendProtocol from deepagents_cli.config import console +logger = logging.getLogger(__name__) + def _run_sandbox_setup(backend: SandboxBackendProtocol, setup_script_path: str) -> None: """Run users setup script in sandbox with env var expansion. @@ -93,8 +99,8 @@ def create_modal_sandbox( process.wait() if process.returncode == 0: break - except Exception: - pass + except Exception as err: # noqa: BLE001 + logger.debug("Modal sandbox not ready yet.", exc_info=err) time.sleep(2) else: # Timeout - cleanup and fail @@ -116,8 +122,8 @@ def create_modal_sandbox( console.print(f"[dim]Terminating Modal sandbox {sandbox_id}...[/dim]") sandbox.terminate() console.print(f"[dim]✓ Modal sandbox {sandbox_id} terminated[/dim]") - except Exception as e: - console.print(f"[yellow]⚠ Cleanup failed: {e}[/yellow]") + except Exception as err: # noqa: BLE001 + console.print(f"[yellow]⚠ Cleanup failed: {err}[/yellow]") @contextmanager @@ -188,8 +194,8 @@ def create_runloop_sandbox( console.print(f"[dim]Shutting down Runloop devbox {sandbox_id}...[/dim]") client.devboxes.shutdown(id=devbox.id) console.print(f"[dim]✓ Runloop devbox {sandbox_id} terminated[/dim]") - except Exception as e: - console.print(f"[yellow]⚠ Cleanup failed: {e}[/yellow]") + except Exception as err: # noqa: BLE001 + console.print(f"[yellow]⚠ Cleanup failed: {err}[/yellow]") @contextmanager @@ -238,8 +244,8 @@ def create_daytona_sandbox( result = sandbox.process.exec("echo ready", timeout=5) if result.exit_code == 0: break - except Exception: - pass + except Exception as err: # noqa: BLE001 + logger.debug("Daytona sandbox not ready yet.", exc_info=err) time.sleep(2) else: try: @@ -262,8 +268,8 @@ def create_daytona_sandbox( try: sandbox.delete() console.print(f"[dim]✓ Daytona sandbox {sandbox_id} terminated[/dim]") - except Exception as e: - console.print(f"[yellow]⚠ Cleanup failed: {e}[/yellow]") + except Exception as err: # noqa: BLE001 + console.print(f"[yellow]⚠ Cleanup failed: {err}[/yellow]") _PROVIDER_TO_WORKING_DIR = { diff --git a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/main.py b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/main.py index e391a62..1cdf2f5 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/main.py +++ b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/main.py @@ -1,4 +1,7 @@ -"""Main entry point and CLI loop for deepagents.""" +"""deepagents CLI의 메인 엔트리포인트 및 루프입니다. + +Main entry point and CLI loop for deepagents. +""" # ruff: noqa: T201 import argparse @@ -227,8 +230,8 @@ async def run_textual_cli_async( cwd=Path.cwd(), thread_id=thread_id, ) - except Exception as e: - console.print(f"[red]❌ Failed to create agent: {e}[/red]") + except Exception as err: # noqa: BLE001 + console.print(f"[red]❌ Failed to create agent: {err}[/red]") sys.exit(1) finally: # Clean up sandbox if we created one @@ -237,7 +240,7 @@ async def run_textual_cli_async( sandbox_cm.__exit__(None, None, None) -def cli_main() -> None: +def cli_main() -> None: # noqa: PLR0912, PLR0915 """Entry point for console script.""" # Fix for gRPC fork issue on macOS # https://github.com/grpc/grpc/issues/37642 diff --git a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/project_utils.py b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/project_utils.py index 762f1ff..0deea01 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/project_utils.py +++ b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/project_utils.py @@ -1,10 +1,10 @@ -"""Utilities for project root detection and project-specific configuration.""" +"""프로젝트 루트 탐지 및 프로젝트별 설정을 위한 유틸리티입니다.""" from pathlib import Path def find_project_root(start_path: Path | None = None) -> Path | None: - """Find the project root by looking for .git directory. + """`.git` 디렉토리를 기준으로 프로젝트 루트를 찾습니다. Walks up the directory tree from start_path (or cwd) looking for a .git directory, which indicates the project root. @@ -17,7 +17,7 @@ def find_project_root(start_path: Path | None = None) -> Path | None: """ current = Path(start_path or Path.cwd()).resolve() - # Walk up the directory tree + # 디렉토리 트리를 위로 올라가며 탐색 for parent in [current, *list(current.parents)]: git_dir = parent / ".git" if git_dir.exists(): @@ -27,7 +27,7 @@ def find_project_root(start_path: Path | None = None) -> Path | None: def find_project_agent_md(project_root: Path) -> list[Path]: - """Find project-specific agent.md file(s). + """프로젝트 전용 `agent.md` 파일을 찾습니다(복수 가능). Checks two locations and returns ALL that exist: 1. project_root/.deepagents/agent.md @@ -43,12 +43,12 @@ def find_project_agent_md(project_root: Path) -> list[Path]: """ paths = [] - # Check .deepagents/agent.md (preferred) + # .deepagents/agent.md 확인(우선) deepagents_md = project_root / ".deepagents" / "agent.md" if deepagents_md.exists(): paths.append(deepagents_md) - # Check root agent.md (fallback, but also include if both exist) + # 루트 agent.md 확인(폴백이지만 둘 다 있으면 함께 포함) root_md = project_root / "agent.md" if root_md.exists(): paths.append(root_md) diff --git a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/sessions.py b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/sessions.py index 8d67cb0..004dee1 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/sessions.py +++ b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/sessions.py @@ -1,4 +1,7 @@ -"""Thread management using LangGraph's built-in checkpoint persistence.""" +"""LangGraph 체크포인트 저장 기능을 사용한 스레드/세션 관리입니다. + +Thread management using LangGraph's built-in checkpoint persistence. +""" import uuid from collections.abc import AsyncIterator diff --git a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/shell.py b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/shell.py index a5521be..28c11c4 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/shell.py +++ b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/shell.py @@ -1,4 +1,7 @@ -"""Simplified middleware that exposes a basic shell tool to agents.""" +"""에이전트에 기본 셸 도구를 노출하는 간단한 미들웨어입니다. + +Simplified middleware that exposes a basic shell tool to agents. +""" from __future__ import annotations @@ -89,7 +92,7 @@ class ShellMiddleware(AgentMiddleware[AgentState, Any]): raise ToolException(msg) try: - result = subprocess.run( + result = subprocess.run( # noqa: S602 command, check=False, shell=True, @@ -106,8 +109,7 @@ class ShellMiddleware(AgentMiddleware[AgentState, Any]): output_parts.append(result.stdout) if result.stderr: stderr_lines = result.stderr.strip().split("\n") - for line in stderr_lines: - output_parts.append(f"[stderr] {line}") + output_parts.extend([f"[stderr] {line}" for line in stderr_lines]) output = "\n".join(output_parts) if output_parts else "" diff --git a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/skills/__init__.py b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/skills/__init__.py index f28ed3a..4db5b51 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/skills/__init__.py +++ b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/skills/__init__.py @@ -1,4 +1,4 @@ -"""Skills module for deepagents CLI. +"""deepagents CLI에서 스킬(skills) 관리를 위한 모듈입니다. Public API: - execute_skills_command: Execute skills subcommands (list/create/info) diff --git a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/skills/commands.py b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/skills/commands.py index 22e9ddf..b5f1e33 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/skills/commands.py +++ b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/skills/commands.py @@ -1,4 +1,4 @@ -"""CLI commands for skill management. +"""CLI에서 스킬(skills)을 관리하기 위한 커맨드들입니다. These commands are registered with the CLI via cli.py: - deepagents skills list --agent [--project] @@ -9,7 +9,6 @@ These commands are registered with the CLI via cli.py: import argparse import re from pathlib import Path -from typing import Any from deepagents_cli.config import COLORS, Settings, console from deepagents_cli.skills.load import list_skills @@ -71,23 +70,15 @@ def _validate_skill_path(skill_dir: Path, base_dir: Path) -> tuple[bool, str]: # Resolve both paths to their canonical form resolved_skill = skill_dir.resolve() resolved_base = base_dir.resolve() - - # Check if skill_dir is within base_dir - # Use is_relative_to if available (Python 3.9+), otherwise use string comparison - if hasattr(resolved_skill, "is_relative_to"): - if not resolved_skill.is_relative_to(resolved_base): - return False, f"Skill directory must be within {base_dir}" - else: - # Fallback for older Python versions - try: - resolved_skill.relative_to(resolved_base) - except ValueError: - return False, f"Skill directory must be within {base_dir}" - - return True, "" except (OSError, RuntimeError) as e: return False, f"Invalid path: {e}" + # Check if skill_dir is within base_dir (Python 3.9+) + if not resolved_skill.is_relative_to(resolved_base): + return False, f"Skill directory must be within {base_dir}" + + return True, "" + def _list(agent: str, *, project: bool = False) -> None: """List all available skills for the specified agent. @@ -114,11 +105,17 @@ def _list(agent: str, *, project: bool = False) -> None: if not project_skills_dir.exists() or not any(project_skills_dir.iterdir()): console.print("[yellow]No project skills found.[/yellow]") console.print( - f"[dim]Project skills will be created in {project_skills_dir}/ when you add them.[/dim]", + ( + f"[dim]Project skills will be created in {project_skills_dir}/ " + "when you add them.[/dim]" + ), style=COLORS["dim"], ) console.print( - "\n[dim]Create a project skill:\n deepagents skills create my-skill --project[/dim]", + ( + "\n[dim]Create a project skill:\n" + " deepagents skills create my-skill --project[/dim]" + ), style=COLORS["dim"], ) return @@ -127,12 +124,18 @@ def _list(agent: str, *, project: bool = False) -> None: console.print("\n[bold]Project Skills:[/bold]\n", style=COLORS["primary"]) else: # Load both user and project skills - skills = list_skills(user_skills_dir=user_skills_dir, project_skills_dir=project_skills_dir) + skills = list_skills( + user_skills_dir=user_skills_dir, + project_skills_dir=project_skills_dir, + ) if not skills: console.print("[yellow]No skills found.[/yellow]") console.print( - "[dim]Skills will be created in ~/.deepagents/agent/skills/ when you add them.[/dim]", + ( + "[dim]Skills will be created in ~/.deepagents/agent/skills/ " + "when you add them.[/dim]" + ), style=COLORS["dim"], ) console.print( @@ -170,7 +173,7 @@ def _list(agent: str, *, project: bool = False) -> None: console.print() -def _create(skill_name: str, agent: str, project: bool = False) -> None: +def _create(skill_name: str, agent: str, *, project: bool = False) -> None: """Create a new skill with a template SKILL.md file. Args: @@ -325,7 +328,8 @@ def _info(skill_name: str, *, agent: str = "agent", project: bool = False) -> No Args: skill_name: Name of the skill to show info for. agent: Agent identifier for skills (default: agent). - project: If True, only search in project skills. If False, search in both user and project skills. + project: If True, only search in project skills. + If False, search in both user and project skills. """ settings = Settings.from_environment() user_skills_dir = settings.get_user_skills_dir(agent) @@ -359,7 +363,10 @@ def _info(skill_name: str, *, agent: str = "agent", project: bool = False) -> No source_color = "green" if skill["source"] == "project" else "cyan" console.print( - f"\n[bold]Skill: {skill['name']}[/bold] [bold {source_color}]({source_label})[/bold {source_color}]\n", + ( + f"\n[bold]Skill: {skill['name']}[/bold] " + f"[bold {source_color}]({source_label})[/bold {source_color}]\n" + ), style=COLORS["primary"], ) console.print(f"[bold]Description:[/bold] {skill['description']}\n", style=COLORS["dim"]) @@ -382,7 +389,7 @@ def _info(skill_name: str, *, agent: str = "agent", project: bool = False) -> No def setup_skills_parser( - subparsers: Any, + subparsers: argparse._SubParsersAction[argparse.ArgumentParser], ) -> argparse.ArgumentParser: """Setup the skills subcommand parser with all its subcommands.""" skills_parser = subparsers.add_parser( @@ -394,7 +401,9 @@ def setup_skills_parser( # Skills list list_parser = skills_subparsers.add_parser( - "list", help="List all available skills", description="List all available skills" + "list", + help="List all available skills", + description="List all available skills", ) list_parser.add_argument( "--agent", @@ -457,7 +466,10 @@ def execute_skills_command(args: argparse.Namespace) -> None: if not is_valid: console.print(f"[bold red]Error:[/bold red] Invalid agent name: {error_msg}") console.print( - "[dim]Agent names must only contain letters, numbers, hyphens, and underscores.[/dim]", + ( + "[dim]Agent names must only contain letters, numbers, hyphens, " + "and underscores.[/dim]" + ), style=COLORS["dim"], ) return diff --git a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/skills/load.py b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/skills/load.py index 410601e..bd5858e 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/skills/load.py +++ b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/skills/load.py @@ -1,4 +1,4 @@ -"""Skill loader for CLI commands. +"""CLI 커맨드용 스킬 로더(파일시스템 기반)입니다. This module provides filesystem-based skill loading for CLI operations (list, create, info). It wraps the prebuilt middleware functionality from deepagents.middleware.skills and adapts @@ -9,12 +9,15 @@ For middleware usage within agents, use deepagents.middleware.skills.SkillsMiddl from __future__ import annotations -from pathlib import Path +from typing import TYPE_CHECKING from deepagents.backends.filesystem import FilesystemBackend from deepagents.middleware.skills import SkillMetadata from deepagents.middleware.skills import _list_skills as list_skills_from_backend +if TYPE_CHECKING: + from pathlib import Path + class ExtendedSkillMetadata(SkillMetadata): """Extended skill metadata for CLI display, adds source tracking.""" diff --git a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/textual_adapter.py b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/textual_adapter.py index f6c1fb4..30bd140 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/textual_adapter.py +++ b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/textual_adapter.py @@ -1,4 +1,7 @@ -"""Textual UI adapter for agent execution.""" +"""에이전트 실행을 Textual UI에 연결하는 어댑터입니다. + +Textual UI adapter for agent execution. +""" # ruff: noqa: PLR0912, PLR0915, ANN401, PLR2004, BLE001, TRY203 # This module has complex streaming logic ported from execution.py @@ -498,7 +501,8 @@ async def execute_task_textual( elif isinstance(decision, dict) and decision.get("type") == "reject": if tool_msg: tool_msg.set_rejected() - # Only remove from tracking on reject (approved tools need output update) + # Only remove from tracking on reject + # (approved tools need output update). if tool_msg_key and tool_msg_key in adapter._current_tool_messages: del adapter._current_tool_messages[tool_msg_key] diff --git a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/tools.py b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/tools.py index b014b3c..299e5e1 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/tools.py +++ b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/tools.py @@ -1,4 +1,10 @@ -"""Custom tools for the CLI agent.""" +"""CLI 에이전트용 커스텀 도구 모음입니다. + +Custom tools for the CLI agent. +""" + +# NOTE(KR): 이 파일의 `http_request` / `web_search` / `fetch_url` 함수 docstring은 +# LangChain tool description으로 사용될 수 있으므로 번역/수정하지 마세요(영어 유지). from typing import Any, Literal @@ -8,6 +14,8 @@ from tavily import TavilyClient from deepagents_cli.config import settings +_HTTP_ERROR_STATUS_CODE_MIN = 400 + # Initialize Tavily client if API key is available tavily_client = TavilyClient(api_key=settings.tavily_api_key) if settings.has_tavily else None @@ -34,27 +42,31 @@ def http_request( Dictionary with response data including status, headers, and content """ try: - kwargs = {"url": url, "method": method.upper(), "timeout": timeout} - - if headers: - kwargs["headers"] = headers - if params: - kwargs["params"] = params - if data: + json_data: dict[str, Any] | None = None + body_data: str | None = None + if data is not None: if isinstance(data, dict): - kwargs["json"] = data + json_data = data else: - kwargs["data"] = data + body_data = data - response = requests.request(**kwargs) + response = requests.request( + method=method.upper(), + url=url, + headers=headers, + params=params, + data=body_data, + json=json_data, + timeout=timeout, + ) try: content = response.json() - except: + except ValueError: content = response.text return { - "success": response.status_code < 400, + "success": response.status_code < _HTTP_ERROR_STATUS_CODE_MIN, "status_code": response.status_code, "headers": dict(response.headers), "content": content, @@ -77,7 +89,7 @@ def http_request( "content": f"Request error: {e!s}", "url": url, } - except Exception as e: + except Exception as e: # noqa: BLE001 return { "success": False, "status_code": 0, @@ -89,10 +101,11 @@ def http_request( def web_search( query: str, + *, max_results: int = 5, topic: Literal["general", "news", "finance"] = "general", include_raw_content: bool = False, -): +) -> dict[str, Any]: """Search the web using Tavily for current information and documentation. This tool searches the web and returns relevant results. After receiving results, @@ -122,7 +135,9 @@ def web_search( """ if tavily_client is None: return { - "error": "Tavily API key not configured. Please set TAVILY_API_KEY environment variable.", + "error": ( + "Tavily API key not configured. Please set TAVILY_API_KEY environment variable." + ), "query": query, } @@ -133,7 +148,7 @@ def web_search( include_raw_content=include_raw_content, topic=topic, ) - except Exception as e: + except Exception as e: # noqa: BLE001 return {"error": f"Web search error: {e!s}", "query": query} @@ -174,10 +189,19 @@ def fetch_url(url: str, timeout: int = 30) -> dict[str, Any]: markdown_content = markdownify(response.text) return { + "success": True, "url": str(response.url), "markdown_content": markdown_content, "status_code": response.status_code, "content_length": len(markdown_content), } - except Exception as e: - return {"error": f"Fetch URL error: {e!s}", "url": url} + except requests.exceptions.Timeout: + return { + "success": False, + "error": f"Fetch URL timed out after {timeout} seconds", + "url": url, + } + except requests.exceptions.RequestException as e: + return {"success": False, "error": f"Fetch URL request error: {e!s}", "url": url} + except Exception as e: # noqa: BLE001 + return {"success": False, "error": f"Fetch URL error: {e!s}", "url": url} diff --git a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/ui.py b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/ui.py index 41a5277..d740133 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/ui.py +++ b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/ui.py @@ -1,8 +1,11 @@ -"""UI rendering and display utilities for the CLI.""" +"""CLI UI 렌더링/표시 관련 유틸리티입니다. + +UI rendering and display utilities for the CLI. +""" import json +from contextlib import suppress from pathlib import Path -from typing import Any from .config import COLORS, DEEP_AGENTS_ASCII, MAX_ARG_LENGTH, console @@ -14,7 +17,7 @@ def truncate_value(value: str, max_length: int = MAX_ARG_LENGTH) -> str: return value -def format_tool_display(tool_name: str, tool_args: dict) -> str: +def format_tool_display(tool_name: str, tool_args: dict) -> str: # noqa: PLR0911, PLR0912, PLR0915 """Format tool calls for display with tool-specific smart formatting. Shows the most relevant information for each tool type rather than all arguments. @@ -34,32 +37,32 @@ def format_tool_display(tool_name: str, tool_args: dict) -> str: def abbreviate_path(path_str: str, max_length: int = 60) -> str: """Abbreviate a file path intelligently - show basename or relative path.""" + path = Path(path_str) + + # If it's just a filename (no directory parts), return as-is + if len(path.parts) == 1: + return path_str + + # Try to get relative path from current working directory try: - path = Path(path_str) + cwd = Path.cwd() + except OSError: + cwd = None - # If it's just a filename (no directory parts), return as-is - if len(path.parts) == 1: - return path_str - - # Try to get relative path from current working directory - try: - rel_path = path.relative_to(Path.cwd()) + if cwd is not None: + with suppress(ValueError): + rel_path = path.relative_to(cwd) rel_str = str(rel_path) # Use relative if it's shorter and not too long if len(rel_str) < len(path_str) and len(rel_str) <= max_length: return rel_str - except (ValueError, Exception): - pass - # If absolute path is reasonable length, use it - if len(path_str) <= max_length: - return path_str + # If absolute path is reasonable length, use it + if len(path_str) <= max_length: + return path_str - # Otherwise, just show basename (filename only) - return path.name - except Exception: - # Fallback to original string if any error - return truncate_value(path_str, max_length) + # Otherwise, just show basename (filename only) + return path.name # Tool-specific formatting - show the most important argument(s) if tool_name in ("read_file", "write_file", "edit_file"): @@ -144,7 +147,7 @@ def format_tool_display(tool_name: str, tool_args: dict) -> str: return f"{tool_name}({args_str})" -def format_tool_message_content(content: Any) -> str: +def format_tool_message_content(content: object) -> str: """Convert ToolMessage content into a printable string.""" if content is None: return "" @@ -156,7 +159,7 @@ def format_tool_message_content(content: Any) -> str: else: try: parts.append(json.dumps(item)) - except Exception: + except (TypeError, ValueError): parts.append(str(item)) return "\n".join(parts) return str(content) diff --git a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/__init__.py b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/__init__.py index ef78056..9083eb4 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/__init__.py +++ b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/__init__.py @@ -1,4 +1,7 @@ -"""Textual widgets for deepagents-cli.""" +"""deepagents-cli에서 사용하는 Textual 위젯 모음입니다. + +Textual widgets for deepagents-cli. +""" from __future__ import annotations diff --git a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/approval.py b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/approval.py index 2478e5a..3d2547a 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/approval.py +++ b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/approval.py @@ -1,12 +1,12 @@ -"""Approval widget for HITL - using standard Textual patterns.""" +"""HITL(승인)용 Approval 위젯입니다(Textual 표준 패턴 기반). + +Approval widget for HITL - using standard Textual patterns. +""" from __future__ import annotations -import asyncio -from typing import Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar -from textual import events -from textual.app import ComposeResult from textual.binding import Binding, BindingType from textual.containers import Container, Vertical, VerticalScroll from textual.message import Message @@ -14,6 +14,12 @@ from textual.widgets import Static from deepagents_cli.widgets.tool_renderers import get_renderer +if TYPE_CHECKING: + import asyncio + + from textual import events + from textual.app import ComposeResult + class ApprovalMenu(Container): """Approval menu using standard Textual patterns. @@ -50,6 +56,7 @@ class ApprovalMenu(Container): """Message sent when user makes a decision.""" def __init__(self, decision: dict[str, str]) -> None: + """Create the message with the selected decision payload.""" super().__init__() self.decision = decision @@ -60,6 +67,7 @@ class ApprovalMenu(Container): id: str | None = None, # noqa: A002 **kwargs: Any, ) -> None: + """Create the approval menu widget for a single action request.""" super().__init__(id=id or "approval-menu", classes="approval-menu", **kwargs) self._action_request = action_request self._assistant_id = assistant_id @@ -90,7 +98,7 @@ class ApprovalMenu(Container): # Options container FIRST - always visible at top with Container(classes="approval-options-container"): # Options - create 3 Static widgets - for i in range(3): + for _ in range(3): widget = Static("", classes="approval-option") self._option_widgets.append(widget) yield widget @@ -138,7 +146,7 @@ class ApprovalMenu(Container): ] for i, (text, widget) in enumerate(zip(options, self._option_widgets, strict=True)): - cursor = "› " if i == self._selected else " " + cursor = "> " if i == self._selected else " " widget.update(f"{cursor}{text}") # Update classes @@ -194,6 +202,6 @@ class ApprovalMenu(Container): # Post message self.post_message(self.Decided(decision)) - def on_blur(self, event: events.Blur) -> None: + def on_blur(self, _event: events.Blur) -> None: """Re-focus on blur to keep focus trapped.""" self.call_after_refresh(self.focus) diff --git a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/autocomplete.py b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/autocomplete.py index e7d508c..5b16537 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/autocomplete.py +++ b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/autocomplete.py @@ -1,4 +1,4 @@ -"""Autocomplete system for @ mentions and / commands. +"""@ 멘션 및 / 커맨드용 자동완성(Autocomplete) 시스템입니다. This is a custom implementation that handles trigger-based completion for slash commands (/) and file mentions (@). diff --git a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/chat_input.py b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/chat_input.py index 63daff3..51989c5 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/chat_input.py +++ b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/chat_input.py @@ -1,4 +1,7 @@ -"""Chat input widget for deepagents-cli with autocomplete and history support.""" +"""자동완성/히스토리를 지원하는 deepagents-cli 채팅 입력 위젯입니다. + +Chat input widget for deepagents-cli with autocomplete and history support. +""" from __future__ import annotations diff --git a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/diff.py b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/diff.py index f4b2a19..0f16950 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/diff.py +++ b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/diff.py @@ -1,4 +1,7 @@ -"""Enhanced diff widget for displaying unified diffs.""" +"""unified diff를 표시하기 위한 확장(diff) 위젯입니다. + +Enhanced diff widget for displaying unified diffs. +""" from __future__ import annotations @@ -25,7 +28,7 @@ def _escape_markup(text: str) -> str: return text.replace("[", r"\[").replace("]", r"\]") -def format_diff_textual(diff: str, max_lines: int | None = 100) -> str: +def format_diff_textual(diff: str, max_lines: int | None = 100) -> str: # noqa: PLR0912 """Format a unified diff with line numbers and colors. Args: diff --git a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/history.py b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/history.py index 9efc89d..72b874c 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/history.py +++ b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/history.py @@ -1,4 +1,4 @@ -"""Command history manager for input persistence.""" +"""입력 히스토리를 파일로 지속(persist)하기 위한 커맨드 히스토리 관리자입니다.""" from __future__ import annotations @@ -7,14 +7,14 @@ from pathlib import Path # noqa: TC003 - used at runtime in type hints class HistoryManager: - """Manages command history with file persistence. + """파일 지속을 포함한 커맨드 히스토리를 관리합니다. Uses append-only writes for concurrent safety. Multiple agents can safely write to the same history file without corruption. """ def __init__(self, history_file: Path, max_entries: int = 100) -> None: - """Initialize the history manager. + """히스토리 관리자를 초기화합니다. Args: history_file: Path to the JSON-lines history file @@ -28,7 +28,7 @@ class HistoryManager: self._load_history() def _load_history(self) -> None: - """Load history from file.""" + """파일에서 히스토리를 로드합니다.""" if not self.history_file.exists(): return @@ -49,7 +49,7 @@ class HistoryManager: self._entries = [] def _append_to_file(self, text: str) -> None: - """Append a single entry to history file (concurrent-safe).""" + """히스토리 파일에 항목 하나를 append 합니다(concurrent-safe).""" try: self.history_file.parent.mkdir(parents=True, exist_ok=True) with self.history_file.open("a", encoding="utf-8") as f: @@ -58,7 +58,7 @@ class HistoryManager: pass def _compact_history(self) -> None: - """Rewrite history file to remove old entries. + """오래된 항목을 제거하기 위해 히스토리 파일을 재작성합니다. Only called when entries exceed 2x max_entries to minimize rewrites. """ @@ -71,26 +71,26 @@ class HistoryManager: pass def add(self, text: str) -> None: - """Add a command to history. + """커맨드를 히스토리에 추가합니다. Args: text: The command text to add """ text = text.strip() - # Skip empty or slash commands + # 빈 문자열 또는 slash 커맨드는 스킵 if not text or text.startswith("/"): return - # Skip duplicates of the last entry + # 직전 항목과 중복이면 스킵 if self._entries and self._entries[-1] == text: return self._entries.append(text) - # Append to file (fast, concurrent-safe) + # 파일에 append(빠르고 concurrent-safe) self._append_to_file(text) - # Compact only when we have 2x max entries (rare operation) + # 엔트리가 2배를 초과할 때만 compact(드문 작업) if len(self._entries) > self.max_entries * 2: self._entries = self._entries[-self.max_entries :] self._compact_history() @@ -98,7 +98,7 @@ class HistoryManager: self.reset_navigation() def get_previous(self, current_input: str, prefix: str = "") -> str | None: - """Get the previous history entry. + """이전 히스토리 항목을 가져옵니다. Args: current_input: Current input text (saved on first navigation) @@ -110,12 +110,12 @@ class HistoryManager: if not self._entries: return None - # Save current input on first navigation + # 첫 네비게이션 시 현재 입력을 저장 if self._current_index == -1: self._temp_input = current_input self._current_index = len(self._entries) - # Search backwards for matching entry + # 뒤로 탐색하며 prefix에 매칭되는 항목을 찾음 for i in range(self._current_index - 1, -1, -1): if self._entries[i].startswith(prefix): self._current_index = i @@ -124,7 +124,7 @@ class HistoryManager: return None def get_next(self, prefix: str = "") -> str | None: - """Get the next history entry. + """다음 히스토리 항목을 가져옵니다. Args: prefix: Optional prefix to filter entries @@ -135,18 +135,18 @@ class HistoryManager: if self._current_index == -1: return None - # Search forwards for matching entry + # 앞으로 탐색하며 prefix에 매칭되는 항목을 찾음 for i in range(self._current_index + 1, len(self._entries)): if self._entries[i].startswith(prefix): self._current_index = i return self._entries[i] - # Return to original input at the end + # 끝까지 가면 원래 입력으로 복귀 result = self._temp_input self.reset_navigation() return result def reset_navigation(self) -> None: - """Reset navigation state.""" + """네비게이션 상태를 초기화합니다.""" self._current_index = -1 self._temp_input = "" diff --git a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/loading.py b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/loading.py index f19bd20..07dcd20 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/loading.py +++ b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/loading.py @@ -1,4 +1,7 @@ -"""Loading widget with animated spinner for agent activity.""" +"""에이전트 동작 중 표시하는 로딩(스피너) 위젯입니다. + +Loading widget with animated spinner for agent activity. +""" from __future__ import annotations diff --git a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/messages.py b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/messages.py index 887943a..715b4ea 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/messages.py +++ b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/messages.py @@ -1,4 +1,7 @@ -"""Message widgets for deepagents-cli.""" +"""deepagents-cli에서 메시지(대화/툴 호출 등)를 표시하는 위젯 모음입니다. + +Message widgets for deepagents-cli. +""" from __future__ import annotations @@ -7,13 +10,13 @@ from typing import TYPE_CHECKING, Any from textual.containers import Vertical from textual.css.query import NoMatches from textual.widgets import Markdown, Static -from textual.widgets._markdown import MarkdownStream from deepagents_cli.ui import format_tool_display from deepagents_cli.widgets.diff import format_diff_textual if TYPE_CHECKING: from textual.app import ComposeResult + from textual.widgets._markdown import MarkdownStream # Maximum number of tool arguments to display inline _MAX_INLINE_ARGS = 3 diff --git a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/status.py b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/status.py index 78d81df..f29eaab 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/status.py +++ b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/status.py @@ -1,4 +1,4 @@ -"""Status bar widget for deepagents-cli.""" +"""deepagents-cli용 상태 표시줄(Status bar) 위젯입니다.""" from __future__ import annotations @@ -13,9 +13,11 @@ from textual.widgets import Static if TYPE_CHECKING: from textual.app import ComposeResult +TOKENS_K_THRESHOLD = 1000 + class StatusBar(Horizontal): - """Status bar showing mode, auto-approve status, and working directory.""" + """모드/자동 승인/작업 디렉토리 등을 표시하는 상태 표시줄입니다.""" DEFAULT_CSS = """ StatusBar { @@ -90,18 +92,18 @@ class StatusBar(Horizontal): tokens: reactive[int] = reactive(0, init=False) def __init__(self, cwd: str | Path | None = None, **kwargs: Any) -> None: - """Initialize the status bar. + """상태 표시줄을 초기화합니다. Args: cwd: Current working directory to display **kwargs: Additional arguments passed to parent """ super().__init__(**kwargs) - # Store initial cwd - will be used in compose() + # 초기 cwd를 저장(compose()에서 사용) self._initial_cwd = str(cwd) if cwd else str(Path.cwd()) def compose(self) -> ComposeResult: - """Compose the status bar layout.""" + """상태 표시줄 레이아웃을 구성합니다.""" yield Static("", classes="status-mode normal", id="mode-indicator") yield Static( "manual | shift+tab to cycle", @@ -113,11 +115,11 @@ class StatusBar(Horizontal): # CWD shown in welcome banner, not pinned in status bar def on_mount(self) -> None: - """Set reactive values after mount to trigger watchers safely.""" + """마운트(on_mount) 이후 reactive 값을 설정해 watcher가 안전하게 동작하도록 합니다.""" self.cwd = self._initial_cwd def watch_mode(self, mode: str) -> None: - """Update mode indicator when mode changes.""" + """모드(mode) 변경 시 표시를 갱신합니다.""" try: indicator = self.query_one("#mode-indicator", Static) except NoMatches: @@ -135,7 +137,7 @@ class StatusBar(Horizontal): indicator.add_class("normal") def watch_auto_approve(self, new_value: bool) -> None: # noqa: FBT001 - """Update auto-approve indicator when state changes.""" + """auto-approve 상태 변경 시 표시를 갱신합니다.""" try: indicator = self.query_one("#auto-approve-indicator", Static) except NoMatches: @@ -150,7 +152,7 @@ class StatusBar(Horizontal): indicator.add_class("off") def watch_cwd(self, new_value: str) -> None: - """Update cwd display when it changes.""" + """작업 디렉토리(cwd) 변경 시 표시를 갱신합니다.""" try: display = self.query_one("#cwd-display", Static) except NoMatches: @@ -158,7 +160,7 @@ class StatusBar(Horizontal): display.update(self._format_cwd(new_value)) def watch_status_message(self, new_value: str) -> None: - """Update status message display.""" + """상태 메시지(status message) 변경 시 표시를 갱신합니다.""" try: msg_widget = self.query_one("#status-message", Static) except NoMatches: @@ -173,10 +175,10 @@ class StatusBar(Horizontal): msg_widget.update("") def _format_cwd(self, cwd_path: str = "") -> str: - """Format the current working directory for display.""" + """표시용으로 현재 작업 디렉토리를 포맷팅합니다.""" path = Path(cwd_path or self.cwd or self._initial_cwd) try: - # Try to use ~ for home directory + # 홈 디렉토리는 ~로 표시 시도 home = Path.home() if path.is_relative_to(home): return "~/" + str(path.relative_to(home)) @@ -185,7 +187,7 @@ class StatusBar(Horizontal): return str(path) def set_mode(self, mode: str) -> None: - """Set the current input mode. + """현재 입력 모드를 설정합니다. Args: mode: One of "normal", "bash", or "command" @@ -193,7 +195,7 @@ class StatusBar(Horizontal): self.mode = mode def set_auto_approve(self, *, enabled: bool) -> None: - """Set the auto-approve state. + """auto-approve 상태를 설정합니다. Args: enabled: Whether auto-approve is enabled @@ -201,7 +203,7 @@ class StatusBar(Horizontal): self.auto_approve = enabled def set_status_message(self, message: str) -> None: - """Set the status message. + """상태 메시지를 설정합니다. Args: message: Status message to display (empty string to clear) @@ -209,23 +211,23 @@ class StatusBar(Horizontal): self.status_message = message def watch_tokens(self, new_value: int) -> None: - """Update token display when count changes.""" + """토큰 수 변경 시 표시를 갱신합니다.""" try: display = self.query_one("#tokens-display", Static) except NoMatches: return if new_value > 0: - # Format with K suffix for thousands - if new_value >= 1000: - display.update(f"{new_value / 1000:.1f}K tokens") + # 천 단위는 K suffix로 표시 + if new_value >= TOKENS_K_THRESHOLD: + display.update(f"{new_value / TOKENS_K_THRESHOLD:.1f}K tokens") else: display.update(f"{new_value} tokens") else: display.update("") def set_tokens(self, count: int) -> None: - """Set the token count. + """토큰 수를 설정합니다. Args: count: Current context token count diff --git a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/tool_renderers.py b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/tool_renderers.py index 6b3bb12..9a71116 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/tool_renderers.py +++ b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/tool_renderers.py @@ -1,4 +1,4 @@ -"""Tool renderers for approval widgets - registry pattern.""" +"""승인(approval) 위젯용 tool renderer들(레지스트리 패턴)입니다.""" from __future__ import annotations @@ -15,14 +15,16 @@ from deepagents_cli.widgets.tool_widgets import ( if TYPE_CHECKING: from deepagents_cli.widgets.tool_widgets import ToolApprovalWidget +DIFF_HEADER_LINES = 2 + class ToolRenderer: - """Base renderer for tool approval widgets.""" + """tool 승인 위젯 렌더러의 베이스 클래스입니다.""" def get_approval_widget( self, tool_args: dict[str, Any] ) -> tuple[type[ToolApprovalWidget], dict[str, Any]]: - """Get the approval widget class and data for this tool. + """이 tool에 대한 승인 위젯 클래스와 데이터를 반환합니다. Args: tool_args: The tool arguments from action_request @@ -34,16 +36,17 @@ class ToolRenderer: class WriteFileRenderer(ToolRenderer): - """Renderer for write_file tool - shows full file content.""" + """`write_file` tool 렌더러(전체 파일 내용을 표시).""" def get_approval_widget( self, tool_args: dict[str, Any] ) -> tuple[type[ToolApprovalWidget], dict[str, Any]]: - # Extract file extension for syntax highlighting + """`write_file` 요청을 표시할 승인 위젯과 데이터를 구성합니다.""" + # 문법 하이라이팅을 위해 확장자를 추출 file_path = tool_args.get("file_path", "") content = tool_args.get("content", "") - # Get file extension + # 파일 확장자 file_extension = "text" if "." in file_path: file_extension = file_path.rsplit(".", 1)[-1] @@ -57,16 +60,17 @@ class WriteFileRenderer(ToolRenderer): class EditFileRenderer(ToolRenderer): - """Renderer for edit_file tool - shows unified diff.""" + """`edit_file` tool 렌더러(unified diff 표시).""" def get_approval_widget( self, tool_args: dict[str, Any] ) -> tuple[type[ToolApprovalWidget], dict[str, Any]]: + """`edit_file` 요청을 unified diff 형태로 표시할 승인 위젯/데이터를 구성합니다.""" file_path = tool_args.get("file_path", "") old_string = tool_args.get("old_string", "") new_string = tool_args.get("new_string", "") - # Generate unified diff + # unified diff 생성 diff_lines = self._generate_diff(old_string, new_string) data = { @@ -78,14 +82,14 @@ class EditFileRenderer(ToolRenderer): return EditFileApprovalWidget, data def _generate_diff(self, old_string: str, new_string: str) -> list[str]: - """Generate unified diff lines from old and new strings.""" + """old/new 문자열로부터 unified diff 라인을 생성합니다.""" if not old_string and not new_string: return [] old_lines = old_string.split("\n") if old_string else [] new_lines = new_string.split("\n") if new_string else [] - # Generate unified diff + # unified diff 생성 diff = difflib.unified_diff( old_lines, new_lines, @@ -95,17 +99,18 @@ class EditFileRenderer(ToolRenderer): n=3, # Context lines ) - # Skip the first two header lines (--- and +++) + # 헤더 라인(---, +++)은 제외 diff_list = list(diff) - return diff_list[2:] if len(diff_list) > 2 else diff_list + return diff_list[DIFF_HEADER_LINES:] if len(diff_list) > DIFF_HEADER_LINES else diff_list class BashRenderer(ToolRenderer): - """Renderer for bash/shell tool - shows command.""" + """`bash`/`shell` tool 렌더러(커맨드 표시).""" def get_approval_widget( self, tool_args: dict[str, Any] ) -> tuple[type[ToolApprovalWidget], dict[str, Any]]: + """`bash`/`shell` 요청을 표시할 승인 위젯/데이터를 구성합니다.""" data = { "command": tool_args.get("command", ""), "description": tool_args.get("description", ""), @@ -113,7 +118,7 @@ class BashRenderer(ToolRenderer): return BashApprovalWidget, data -# Registry mapping tool names to renderers +# tool 이름 → renderer 매핑 레지스트리 _RENDERER_REGISTRY: dict[str, type[ToolRenderer]] = { "write_file": WriteFileRenderer, "edit_file": EditFileRenderer, @@ -123,7 +128,7 @@ _RENDERER_REGISTRY: dict[str, type[ToolRenderer]] = { def get_renderer(tool_name: str) -> ToolRenderer: - """Get the renderer for a tool by name. + """도구 이름에 맞는 renderer를 반환합니다. Args: tool_name: The name of the tool diff --git a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/tool_widgets.py b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/tool_widgets.py index 9142bd4..c5c3075 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/tool_widgets.py +++ b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/tool_widgets.py @@ -1,4 +1,7 @@ -"""Tool-specific approval widgets for HITL display.""" +"""HITL(승인) 화면에서 도구별(tool-specific) 표시를 담당하는 위젯들입니다. + +Tool-specific approval widgets for HITL display. +""" from __future__ import annotations diff --git a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/welcome.py b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/welcome.py index 500027d..95148c3 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/welcome.py +++ b/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/widgets/welcome.py @@ -1,4 +1,7 @@ -"""Welcome banner widget for deepagents-cli.""" +"""deepagents-cli 시작 시 보여주는 환영 배너 위젯입니다. + +Welcome banner widget for deepagents-cli. +""" from __future__ import annotations diff --git a/deepagents_sourcecode/libs/deepagents-cli/examples/skills/arxiv-search/arxiv_search.py b/deepagents_sourcecode/libs/deepagents-cli/examples/skills/arxiv-search/arxiv_search.py index 1d13b28..0a937a7 100755 --- a/deepagents_sourcecode/libs/deepagents-cli/examples/skills/arxiv-search/arxiv_search.py +++ b/deepagents_sourcecode/libs/deepagents-cli/examples/skills/arxiv-search/arxiv_search.py @@ -6,6 +6,10 @@ Searches the arXiv preprint repository for research papers. import argparse +from rich.console import Console + +console = Console() + def query_arxiv(query: str, max_papers: int = 10) -> str: """Query arXiv for papers based on the provided search query. @@ -33,12 +37,16 @@ def query_arxiv(query: str, max_papers: int = 10) -> str: results = "\n\n".join( [f"Title: {paper.title}\nSummary: {paper.summary}" for paper in client.results(search)] ) - return results if results else "No papers found on arXiv." - except Exception as e: + except Exception as e: # noqa: BLE001 return f"Error querying arXiv: {e}" + else: + if results: + return results + return "No papers found on arXiv." def main() -> None: + """Run the arXiv search CLI.""" parser = argparse.ArgumentParser(description="Search arXiv for research papers") parser.add_argument("query", type=str, help="Search query string") parser.add_argument( @@ -50,7 +58,7 @@ def main() -> None: args = parser.parse_args() - query_arxiv(args.query, max_papers=args.max_papers) + console.print(query_arxiv(args.query, max_papers=args.max_papers)) if __name__ == "__main__": diff --git a/deepagents_sourcecode/libs/deepagents-cli/examples/skills/skill-creator/scripts/init_skill.py b/deepagents_sourcecode/libs/deepagents-cli/examples/skills/skill-creator/scripts/init_skill.py index a3a517a..dd94361 100755 --- a/deepagents_sourcecode/libs/deepagents-cli/examples/skills/skill-creator/scripts/init_skill.py +++ b/deepagents_sourcecode/libs/deepagents-cli/examples/skills/skill-creator/scripts/init_skill.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -""" -Skill Initializer - Creates a new skill from template +"""Skill Initializer - Creates a new skill from template. Usage: init_skill.py --path @@ -14,9 +13,16 @@ For deepagents CLI: init_skill.py my-skill --path ~/.deepagents/agent/skills """ +# ruff: noqa: E501 + import sys from pathlib import Path +from rich.console import Console + +console = Console() + +MIN_ARGS = 4 SKILL_TEMPLATE = """--- name: {skill_name} @@ -189,14 +195,13 @@ Note: This is a text placeholder. Actual assets can be any file type. """ -def title_case_skill_name(skill_name): +def title_case_skill_name(skill_name: str) -> str: """Convert hyphenated skill name to Title Case for display.""" - return ' '.join(word.capitalize() for word in skill_name.split('-')) + return " ".join(word.capitalize() for word in skill_name.split("-")) -def init_skill(skill_name, path): - """ - Initialize a new skill directory with template SKILL.md. +def init_skill(skill_name: str, path: str | Path) -> Path | None: + """Initialize a new skill directory with template SKILL.md. Args: skill_name: Name of the skill @@ -210,15 +215,15 @@ def init_skill(skill_name, path): # Check if directory already exists if skill_dir.exists(): - print(f"❌ Error: Skill directory already exists: {skill_dir}") + console.print(f"❌ Error: Skill directory already exists: {skill_dir}") return None # Create skill directory try: skill_dir.mkdir(parents=True, exist_ok=False) - print(f"✅ Created skill directory: {skill_dir}") - except Exception as e: - print(f"❌ Error creating directory: {e}") + console.print(f"✅ Created skill directory: {skill_dir}") + except OSError as e: + console.print(f"❌ Error creating directory: {e}") return None # Create SKILL.md from template @@ -228,73 +233,76 @@ def init_skill(skill_name, path): skill_title=skill_title ) - skill_md_path = skill_dir / 'SKILL.md' + skill_md_path = skill_dir / "SKILL.md" try: skill_md_path.write_text(skill_content) - print("✅ Created SKILL.md") - except Exception as e: - print(f"❌ Error creating SKILL.md: {e}") + console.print("✅ Created SKILL.md") + except OSError as e: + console.print(f"❌ Error creating SKILL.md: {e}") return None # Create resource directories with example files try: # Create scripts/ directory with example script - scripts_dir = skill_dir / 'scripts' + scripts_dir = skill_dir / "scripts" scripts_dir.mkdir(exist_ok=True) - example_script = scripts_dir / 'example.py' + example_script = scripts_dir / "example.py" example_script.write_text(EXAMPLE_SCRIPT.format(skill_name=skill_name)) example_script.chmod(0o755) - print("✅ Created scripts/example.py") + console.print("✅ Created scripts/example.py") # Create references/ directory with example reference doc - references_dir = skill_dir / 'references' + references_dir = skill_dir / "references" references_dir.mkdir(exist_ok=True) - example_reference = references_dir / 'api_reference.md' + example_reference = references_dir / "api_reference.md" example_reference.write_text(EXAMPLE_REFERENCE.format(skill_title=skill_title)) - print("✅ Created references/api_reference.md") + console.print("✅ Created references/api_reference.md") # Create assets/ directory with example asset placeholder - assets_dir = skill_dir / 'assets' + assets_dir = skill_dir / "assets" assets_dir.mkdir(exist_ok=True) - example_asset = assets_dir / 'example_asset.txt' + example_asset = assets_dir / "example_asset.txt" example_asset.write_text(EXAMPLE_ASSET) - print("✅ Created assets/example_asset.txt") - except Exception as e: - print(f"❌ Error creating resource directories: {e}") + console.print("✅ Created assets/example_asset.txt") + except OSError as e: + console.print(f"❌ Error creating resource directories: {e}") return None # Print next steps - print(f"\n✅ Skill '{skill_name}' initialized successfully at {skill_dir}") - print("\nNext steps:") - print("1. Edit SKILL.md to complete the TODO items and update the description") - print("2. Customize or delete the example files in scripts/, references/, and assets/") - print("3. Run the validator when ready to check the skill structure") + console.print(f"\n✅ Skill '{skill_name}' initialized successfully at {skill_dir}") + console.print("\nNext steps:") + console.print("1. Edit SKILL.md to complete the TODO items and update the description") + console.print( + "2. Customize or delete the example files in scripts/, references/, and assets/" + ) + console.print("3. Run the validator when ready to check the skill structure") return skill_dir -def main(): - if len(sys.argv) < 4 or sys.argv[2] != '--path': - print("Usage: init_skill.py --path ") - print("\nSkill name requirements:") - print(" - Hyphen-case identifier (e.g., 'data-analyzer')") - print(" - Lowercase letters, digits, and hyphens only") - print(" - Max 40 characters") - print(" - Must match directory name exactly") - print("\nExamples:") - print(" init_skill.py my-new-skill --path skills/public") - print(" init_skill.py my-api-helper --path skills/private") - print(" init_skill.py custom-skill --path /custom/location") - print("\nFor deepagents CLI:") - print(" init_skill.py my-skill --path ~/.deepagents/agent/skills") +def main() -> None: + """Run the skill initializer CLI.""" + if len(sys.argv) < MIN_ARGS or sys.argv[2] != "--path": + console.print("Usage: init_skill.py --path ") + console.print("\nSkill name requirements:") + console.print(" - Hyphen-case identifier (e.g., 'data-analyzer')") + console.print(" - Lowercase letters, digits, and hyphens only") + console.print(" - Max 40 characters") + console.print(" - Must match directory name exactly") + console.print("\nExamples:") + console.print(" init_skill.py my-new-skill --path skills/public") + console.print(" init_skill.py my-api-helper --path skills/private") + console.print(" init_skill.py custom-skill --path /custom/location") + console.print("\nFor deepagents CLI:") + console.print(" init_skill.py my-skill --path ~/.deepagents/agent/skills") sys.exit(1) skill_name = sys.argv[1] path = sys.argv[3] - print(f"🚀 Initializing skill: {skill_name}") - print(f" Location: {path}") - print() + console.print(f"🚀 Initializing skill: {skill_name}") + console.print(f" Location: {path}") + console.print() result = init_skill(skill_name, path) diff --git a/deepagents_sourcecode/libs/deepagents-cli/examples/skills/skill-creator/scripts/quick_validate.py b/deepagents_sourcecode/libs/deepagents-cli/examples/skills/skill-creator/scripts/quick_validate.py index 3290a61..90caba7 100755 --- a/deepagents_sourcecode/libs/deepagents-cli/examples/skills/skill-creator/scripts/quick_validate.py +++ b/deepagents_sourcecode/libs/deepagents-cli/examples/skills/skill-creator/scripts/quick_validate.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -""" -Quick validation script for skills - minimal version +"""Quick validation script for skills - minimal version. For deepagents CLI, skills are located at: ~/.deepagents//skills// @@ -9,28 +8,36 @@ Example: python quick_validate.py ~/.deepagents/agent/skills/my-skill """ -import sys -import os import re -import yaml +import sys from pathlib import Path -def validate_skill(skill_path): - """Basic validation of a skill""" +import yaml +from rich.console import Console + +console = Console() + +MAX_NAME_LENGTH = 64 +MAX_DESCRIPTION_LENGTH = 1024 +EXPECTED_ARG_COUNT = 2 + + +def validate_skill(skill_path: str | Path) -> tuple[bool, str]: # noqa: PLR0911, PLR0912 + """Basic validation of a skill.""" skill_path = Path(skill_path) # Check SKILL.md exists - skill_md = skill_path / 'SKILL.md' + skill_md = skill_path / "SKILL.md" if not skill_md.exists(): return False, "SKILL.md not found" # Read and validate frontmatter content = skill_md.read_text() - if not content.startswith('---'): + if not content.startswith("---"): return False, "No YAML frontmatter found" # Extract frontmatter - match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL) + match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL) if not match: return False, "Invalid frontmatter format" @@ -45,57 +52,67 @@ def validate_skill(skill_path): return False, f"Invalid YAML in frontmatter: {e}" # Define allowed properties - ALLOWED_PROPERTIES = {'name', 'description', 'license', 'allowed-tools', 'metadata'} + allowed_properties = {"name", "description", "license", "allowed-tools", "metadata"} # Check for unexpected properties (excluding nested keys under metadata) - unexpected_keys = set(frontmatter.keys()) - ALLOWED_PROPERTIES + unexpected_keys = set(frontmatter.keys()) - allowed_properties if unexpected_keys: return False, ( f"Unexpected key(s) in SKILL.md frontmatter: {', '.join(sorted(unexpected_keys))}. " - f"Allowed properties are: {', '.join(sorted(ALLOWED_PROPERTIES))}" + f"Allowed properties are: {', '.join(sorted(allowed_properties))}" ) # Check required fields - if 'name' not in frontmatter: + if "name" not in frontmatter: return False, "Missing 'name' in frontmatter" - if 'description' not in frontmatter: + if "description" not in frontmatter: return False, "Missing 'description' in frontmatter" # Extract name for validation - name = frontmatter.get('name', '') + name = frontmatter.get("name", "") if not isinstance(name, str): return False, f"Name must be a string, got {type(name).__name__}" name = name.strip() if name: # Check naming convention (hyphen-case: lowercase with hyphens) - if not re.match(r'^[a-z0-9-]+$', name): - return False, f"Name '{name}' should be hyphen-case (lowercase letters, digits, and hyphens only)" - if name.startswith('-') or name.endswith('-') or '--' in name: - return False, f"Name '{name}' cannot start/end with hyphen or contain consecutive hyphens" + if not re.match(r"^[a-z0-9-]+$", name): + return False, ( + f"Name '{name}' should be hyphen-case (lowercase letters, digits, and hyphens only)" + ) + if name.startswith("-") or name.endswith("-") or "--" in name: + return False, ( + f"Name '{name}' cannot start/end with hyphen or contain consecutive hyphens" + ) # Check name length (max 64 characters per spec) - if len(name) > 64: - return False, f"Name is too long ({len(name)} characters). Maximum is 64 characters." + if len(name) > MAX_NAME_LENGTH: + return False, ( + f"Name is too long ({len(name)} characters). " + f"Maximum is {MAX_NAME_LENGTH} characters." + ) # Extract and validate description - description = frontmatter.get('description', '') + description = frontmatter.get("description", "") if not isinstance(description, str): return False, f"Description must be a string, got {type(description).__name__}" description = description.strip() if description: # Check for angle brackets - if '<' in description or '>' in description: + if "<" in description or ">" in description: return False, "Description cannot contain angle brackets (< or >)" # Check description length (max 1024 characters per spec) - if len(description) > 1024: - return False, f"Description is too long ({len(description)} characters). Maximum is 1024 characters." + if len(description) > MAX_DESCRIPTION_LENGTH: + return False, ( + "Description is too long " + f"({len(description)} characters). Maximum is {MAX_DESCRIPTION_LENGTH} characters." + ) return True, "Skill is valid!" if __name__ == "__main__": - if len(sys.argv) != 2: - print("Usage: python quick_validate.py ") + if len(sys.argv) != EXPECTED_ARG_COUNT: + console.print("Usage: python quick_validate.py ") sys.exit(1) valid, message = validate_skill(sys.argv[1]) - print(message) + console.print(message) sys.exit(0 if valid else 1) diff --git a/deepagents_sourcecode/libs/deepagents-cli/tests/integration_tests/test_sandbox_factory.py b/deepagents_sourcecode/libs/deepagents-cli/tests/integration_tests/test_sandbox_factory.py index c85c182..62ed9ec 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/tests/integration_tests/test_sandbox_factory.py +++ b/deepagents_sourcecode/libs/deepagents-cli/tests/integration_tests/test_sandbox_factory.py @@ -16,6 +16,8 @@ from deepagents.backends.sandbox import BaseSandbox from deepagents_cli.integrations.sandbox_factory import create_sandbox +_SANDBOX_TMP_DIR = "/tmp" # noqa: S108 + class BaseSandboxIntegrationTest(ABC): """Base class for sandbox integration tests. @@ -38,7 +40,7 @@ class BaseSandboxIntegrationTest(ABC): def test_upload_single_file(self, sandbox: SandboxBackendProtocol) -> None: """Test uploading a single file.""" - test_path = "/tmp/test_upload_single.txt" + test_path = f"{_SANDBOX_TMP_DIR}/test_upload_single.txt" test_content = b"Hello, Sandbox!" upload_responses = sandbox.upload_files([(test_path, test_content)]) @@ -52,7 +54,7 @@ class BaseSandboxIntegrationTest(ABC): def test_download_single_file(self, sandbox: SandboxBackendProtocol) -> None: """Test downloading a single file.""" - test_path = "/tmp/test_download_single.txt" + test_path = f"{_SANDBOX_TMP_DIR}/test_download_single.txt" test_content = b"Download test content" # Create file first sandbox.upload_files([(test_path, test_content)]) @@ -67,7 +69,7 @@ class BaseSandboxIntegrationTest(ABC): def test_upload_download_roundtrip(self, sandbox: SandboxBackendProtocol) -> None: """Test upload followed by download for data integrity.""" - test_path = "/tmp/test_roundtrip.txt" + test_path = f"{_SANDBOX_TMP_DIR}/test_roundtrip.txt" test_content = b"Roundtrip test: special chars \n\t\r\x00" # Upload @@ -82,9 +84,9 @@ class BaseSandboxIntegrationTest(ABC): def test_upload_multiple_files(self, sandbox: SandboxBackendProtocol) -> None: """Test uploading multiple files in a single batch.""" files = [ - ("/tmp/test_multi_1.txt", b"Content 1"), - ("/tmp/test_multi_2.txt", b"Content 2"), - ("/tmp/test_multi_3.txt", b"Content 3"), + (f"{_SANDBOX_TMP_DIR}/test_multi_1.txt", b"Content 1"), + (f"{_SANDBOX_TMP_DIR}/test_multi_2.txt", b"Content 2"), + (f"{_SANDBOX_TMP_DIR}/test_multi_3.txt", b"Content 3"), ] upload_responses = sandbox.upload_files(files) @@ -97,9 +99,9 @@ class BaseSandboxIntegrationTest(ABC): def test_download_multiple_files(self, sandbox: SandboxBackendProtocol) -> None: """Test downloading multiple files in a single batch.""" files = [ - ("/tmp/test_batch_1.txt", b"Batch 1"), - ("/tmp/test_batch_2.txt", b"Batch 2"), - ("/tmp/test_batch_3.txt", b"Batch 3"), + (f"{_SANDBOX_TMP_DIR}/test_batch_1.txt", b"Batch 1"), + (f"{_SANDBOX_TMP_DIR}/test_batch_2.txt", b"Batch 2"), + (f"{_SANDBOX_TMP_DIR}/test_batch_3.txt", b"Batch 3"), ] # Upload files first @@ -118,7 +120,7 @@ class BaseSandboxIntegrationTest(ABC): @pytest.mark.skip(reason="Error handling not implemented yet.") def test_download_nonexistent_file(self, sandbox: SandboxBackendProtocol) -> None: """Test that downloading a non-existent file returns an error.""" - nonexistent_path = "/tmp/does_not_exist.txt" + nonexistent_path = f"{_SANDBOX_TMP_DIR}/does_not_exist.txt" download_responses = sandbox.download_files([nonexistent_path]) @@ -129,7 +131,7 @@ class BaseSandboxIntegrationTest(ABC): def test_upload_binary_content(self, sandbox: SandboxBackendProtocol) -> None: """Test uploading binary content (not valid UTF-8).""" - test_path = "/tmp/binary_file.bin" + test_path = f"{_SANDBOX_TMP_DIR}/binary_file.bin" # Create binary content with all byte values test_content = bytes(range(256)) @@ -145,8 +147,8 @@ class BaseSandboxIntegrationTest(ABC): def test_partial_success_upload(self, sandbox: SandboxBackendProtocol) -> None: """Test that batch upload supports partial success.""" files = [ - ("/tmp/valid_upload.txt", b"Valid content"), - ("/tmp/another_valid.txt", b"Another valid"), + (f"{_SANDBOX_TMP_DIR}/valid_upload.txt", b"Valid content"), + (f"{_SANDBOX_TMP_DIR}/another_valid.txt", b"Another valid"), ] upload_responses = sandbox.upload_files(files) @@ -161,12 +163,12 @@ class BaseSandboxIntegrationTest(ABC): def test_partial_success_download(self, sandbox: SandboxBackendProtocol) -> None: """Test that batch download supports partial success.""" # Create one valid file - valid_path = "/tmp/valid_file.txt" + valid_path = f"{_SANDBOX_TMP_DIR}/valid_file.txt" valid_content = b"Valid" sandbox.upload_files([(valid_path, valid_content)]) # Request both valid and invalid files - paths = [valid_path, "/tmp/does_not_exist.txt"] + paths = [valid_path, f"{_SANDBOX_TMP_DIR}/does_not_exist.txt"] download_responses = sandbox.download_files(paths) assert len(download_responses) == 2 @@ -175,7 +177,7 @@ class BaseSandboxIntegrationTest(ABC): assert download_responses[0].content == valid_content assert download_responses[0].error is None # Second should fail - assert download_responses[1].path == "/tmp/does_not_exist.txt" + assert download_responses[1].path == f"{_SANDBOX_TMP_DIR}/does_not_exist.txt" assert download_responses[1].content is None assert download_responses[1].error is not None @@ -188,10 +190,10 @@ class BaseSandboxIntegrationTest(ABC): Expected behavior: download_files should return FileDownloadResponse with error='file_not_found' when the requested file doesn't exist. """ - responses = sandbox.download_files(["/tmp/nonexistent_test_file.txt"]) + responses = sandbox.download_files([f"{_SANDBOX_TMP_DIR}/nonexistent_test_file.txt"]) assert len(responses) == 1 - assert responses[0].path == "/tmp/nonexistent_test_file.txt" + assert responses[0].path == f"{_SANDBOX_TMP_DIR}/nonexistent_test_file.txt" assert responses[0].content is None assert responses[0].error == "file_not_found" @@ -205,12 +207,12 @@ class BaseSandboxIntegrationTest(ABC): error='is_directory' when trying to download a directory as a file. """ # Create a directory - sandbox.execute("mkdir -p /tmp/test_directory") + sandbox.execute(f"mkdir -p {_SANDBOX_TMP_DIR}/test_directory") - responses = sandbox.download_files(["/tmp/test_directory"]) + responses = sandbox.download_files([f"{_SANDBOX_TMP_DIR}/test_directory"]) assert len(responses) == 1 - assert responses[0].path == "/tmp/test_directory" + assert responses[0].path == f"{_SANDBOX_TMP_DIR}/test_directory" assert responses[0].content is None assert responses[0].error == "is_directory" @@ -229,13 +231,15 @@ class BaseSandboxIntegrationTest(ABC): """ # Try to upload to a path where the parent is a file, not a directory # First create a file - sandbox.upload_files([("/tmp/parent_is_file.txt", b"I am a file")]) + sandbox.upload_files([(f"{_SANDBOX_TMP_DIR}/parent_is_file.txt", b"I am a file")]) # Now try to upload as if parent_is_file.txt were a directory - responses = sandbox.upload_files([("/tmp/parent_is_file.txt/child.txt", b"child")]) + responses = sandbox.upload_files( + [(f"{_SANDBOX_TMP_DIR}/parent_is_file.txt/child.txt", b"child")] + ) assert len(responses) == 1 - assert responses[0].path == "/tmp/parent_is_file.txt/child.txt" + assert responses[0].path == f"{_SANDBOX_TMP_DIR}/parent_is_file.txt/child.txt" # Could be parent_not_found or invalid_path depending on implementation assert responses[0].error in ("parent_not_found", "invalid_path") @@ -249,10 +253,10 @@ class BaseSandboxIntegrationTest(ABC): error='invalid_path' for malformed paths (null bytes, invalid chars, etc). """ # Test with null byte (invalid in most filesystems) - responses = sandbox.upload_files([("/tmp/file\x00name.txt", b"content")]) + responses = sandbox.upload_files([(f"{_SANDBOX_TMP_DIR}/file\x00name.txt", b"content")]) assert len(responses) == 1 - assert responses[0].path == "/tmp/file\x00name.txt" + assert responses[0].path == f"{_SANDBOX_TMP_DIR}/file\x00name.txt" assert responses[0].error == "invalid_path" @pytest.mark.skip( @@ -265,10 +269,10 @@ class BaseSandboxIntegrationTest(ABC): error='invalid_path' for malformed paths (null bytes, invalid chars, etc). """ # Test with null byte (invalid in most filesystems) - responses = sandbox.download_files(["/tmp/file\x00name.txt"]) + responses = sandbox.download_files([f"{_SANDBOX_TMP_DIR}/file\x00name.txt"]) assert len(responses) == 1 - assert responses[0].path == "/tmp/file\x00name.txt" + assert responses[0].path == f"{_SANDBOX_TMP_DIR}/file\x00name.txt" assert responses[0].content is None assert responses[0].error == "invalid_path" @@ -282,13 +286,13 @@ class BaseSandboxIntegrationTest(ABC): an appropriate error. The exact behavior depends on the sandbox provider. """ # Create a directory - sandbox.execute("mkdir -p /tmp/test_dir_upload") + sandbox.execute(f"mkdir -p {_SANDBOX_TMP_DIR}/test_dir_upload") # Try to upload a file with the same name as the directory - responses = sandbox.upload_files([("/tmp/test_dir_upload", b"file content")]) + responses = sandbox.upload_files([(f"{_SANDBOX_TMP_DIR}/test_dir_upload", b"file content")]) assert len(responses) == 1 - assert responses[0].path == "/tmp/test_dir_upload" + assert responses[0].path == f"{_SANDBOX_TMP_DIR}/test_dir_upload" # Behavior depends on implementation - just verify we get a response diff --git a/deepagents_sourcecode/libs/deepagents-cli/tests/integration_tests/test_sandbox_operations.py b/deepagents_sourcecode/libs/deepagents-cli/tests/integration_tests/test_sandbox_operations.py index 0201dc9..818668d 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/tests/integration_tests/test_sandbox_operations.py +++ b/deepagents_sourcecode/libs/deepagents-cli/tests/integration_tests/test_sandbox_operations.py @@ -12,6 +12,8 @@ All tests run on a single sandbox instance (class-scoped fixture) to avoid the overhead of spinning up multiple containers. """ +# ruff: noqa: S108 + from collections.abc import Iterator import pytest @@ -79,7 +81,11 @@ class TestSandboxOperations: def test_write_special_characters(self, sandbox: SandboxBackendProtocol) -> None: """Test writing content with special characters and escape sequences.""" test_path = "/tmp/test_sandbox_ops/special.txt" - content = "Special chars: $VAR, `command`, $(subshell), 'quotes', \"quotes\"\nTab\there\nBackslash: \\" + content = ( + "Special chars: $VAR, `command`, $(subshell), 'quotes', \"quotes\"\n" + "Tab\there\n" + "Backslash: \\" + ) result = sandbox.write(test_path, content) @@ -238,7 +244,11 @@ class TestSandboxOperations: def test_read_unicode_content(self, sandbox: SandboxBackendProtocol) -> None: """Test reading a file with unicode content.""" test_path = "/tmp/test_sandbox_ops/unicode_read.txt" - content = "Hello 👋 世界\nПривет мир\nمرحبا العالم" + content = ( + "Hello 👋 世界\n" + "\u041f\u0440\u0438\u0432\u0435\u0442 \u043c\u0438\u0440\n" + "\u0645\u0631\u062d\u0628\u0627 \u0627\u0644\u0639\u0627\u0644\u0645" + ) sandbox.write(test_path, content) result = sandbox.read(test_path) @@ -734,7 +744,10 @@ class TestSandboxOperations: """Test grep with unicode pattern and content.""" base_dir = "/tmp/test_sandbox_ops/grep_unicode" sandbox.execute(f"mkdir -p {base_dir}") - sandbox.write(f"{base_dir}/unicode.txt", "Hello 世界\nПривет мир\n测试 pattern") + sandbox.write( + f"{base_dir}/unicode.txt", + "Hello 世界\n\u041f\u0440\u0438\u0432\u0435\u0442 \u043c\u0438\u0440\n测试 pattern", + ) result = sandbox.grep_raw("世界", path=base_dir) diff --git a/deepagents_sourcecode/libs/deepagents-cli/tests/unit_tests/skills/test_commands.py b/deepagents_sourcecode/libs/deepagents-cli/tests/unit_tests/skills/test_commands.py index 250ddfb..e48cff0 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/tests/unit_tests/skills/test_commands.py +++ b/deepagents_sourcecode/libs/deepagents-cli/tests/unit_tests/skills/test_commands.py @@ -70,7 +70,7 @@ class TestValidateSkillName: "/etc/passwd", "/home/user/.ssh", "\\Windows\\System32", - "/tmp/exploit", + "/tmp/exploit", # noqa: S108 ] for name in malicious_names: is_valid, error = _validate_name(name) diff --git a/deepagents_sourcecode/libs/deepagents-cli/tests/unit_tests/test_autocomplete.py b/deepagents_sourcecode/libs/deepagents-cli/tests/unit_tests/test_autocomplete.py index 5404f29..c571a90 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/tests/unit_tests/test_autocomplete.py +++ b/deepagents_sourcecode/libs/deepagents-cli/tests/unit_tests/test_autocomplete.py @@ -1,5 +1,6 @@ """Tests for autocomplete fuzzy search functionality.""" +from pathlib import Path from unittest.mock import MagicMock import pytest @@ -16,6 +17,8 @@ from deepagents_cli.widgets.autocomplete import ( _path_depth, ) +SampleFiles = list[str] + class TestFuzzyScore: """Tests for the _fuzzy_score function.""" @@ -65,7 +68,7 @@ class TestFuzzySearch: """Tests for the _fuzzy_search function.""" @pytest.fixture - def sample_files(self): + def sample_files(self) -> SampleFiles: """Sample file list for testing.""" return [ "README.md", @@ -80,39 +83,39 @@ class TestFuzzySearch: "docs/api.md", ] - def test_empty_query_returns_root_files_first(self, sample_files): + def test_empty_query_returns_root_files_first(self, sample_files: SampleFiles) -> None: """Empty query returns files sorted by depth, then name.""" results = _fuzzy_search("", sample_files, limit=5) # Root level files should come first assert results[0] in ["README.md", "setup.py"] assert all("/" not in r for r in results[:2]) # First items are root level - def test_exact_match_ranked_first(self, sample_files): + def test_exact_match_ranked_first(self, sample_files: SampleFiles) -> None: """Exact filename matches are ranked first.""" results = _fuzzy_search("main", sample_files, limit=5) assert "src/main.py" in results[:2] - def test_filters_dotfiles_by_default(self, sample_files): + def test_filters_dotfiles_by_default(self, sample_files: SampleFiles) -> None: """Dotfiles are filtered out by default.""" results = _fuzzy_search("git", sample_files, limit=10) assert not any(".git" in r for r in results) - def test_includes_dotfiles_when_query_starts_with_dot(self, sample_files): + def test_includes_dotfiles_when_query_starts_with_dot(self, sample_files: SampleFiles) -> None: """Dotfiles included when query starts with '.'.""" results = _fuzzy_search(".git", sample_files, limit=10, include_dotfiles=True) assert any(".git" in r for r in results) - def test_respects_limit(self, sample_files): + def test_respects_limit(self, sample_files: SampleFiles) -> None: """Results respect the limit parameter.""" results = _fuzzy_search("", sample_files, limit=3) assert len(results) <= 3 - def test_filters_low_score_matches(self, sample_files): + def test_filters_low_score_matches(self, sample_files: SampleFiles) -> None: """Low score matches are filtered out.""" results = _fuzzy_search("xyznonexistent", sample_files, limit=10) assert len(results) == 0 - def test_utils_matches_multiple_files(self, sample_files): + def test_utils_matches_multiple_files(self, sample_files: SampleFiles) -> None: """Query matching multiple files returns all matches.""" results = _fuzzy_search("utils", sample_files, limit=10) assert len(results) >= 2 @@ -145,7 +148,7 @@ class TestHelperFunctions: class TestFindProjectRoot: """Tests for _find_project_root function.""" - def test_finds_git_root(self, tmp_path): + def test_finds_git_root(self, tmp_path: Path) -> None: """Finds .git directory and returns its parent.""" # Create nested structure with .git at root git_dir = tmp_path / ".git" @@ -156,7 +159,7 @@ class TestFindProjectRoot: result = _find_project_root(nested) assert result == tmp_path - def test_returns_start_path_when_no_git(self, tmp_path): + def test_returns_start_path_when_no_git(self, tmp_path: Path) -> None: """Returns start path when no .git found.""" nested = tmp_path / "some" / "path" nested.mkdir(parents=True) @@ -165,7 +168,7 @@ class TestFindProjectRoot: # Should return the path itself (or a parent) since no .git exists assert result == nested or nested.is_relative_to(result) - def test_handles_root_level_git(self, tmp_path): + def test_handles_root_level_git(self, tmp_path: Path) -> None: """Handles .git at the start path itself.""" git_dir = tmp_path / ".git" git_dir.mkdir() @@ -178,29 +181,32 @@ class TestSlashCommandController: """Tests for SlashCommandController.""" @pytest.fixture - def mock_view(self): + def mock_view(self) -> MagicMock: """Create a mock CompletionView.""" - view = MagicMock() - return view + return MagicMock() @pytest.fixture - def controller(self, mock_view): + def controller(self, mock_view: MagicMock) -> SlashCommandController: """Create a SlashCommandController with mock view.""" return SlashCommandController(SLASH_COMMANDS, mock_view) - def test_can_handle_slash_prefix(self, controller): + def test_can_handle_slash_prefix(self, controller: SlashCommandController) -> None: """Handles text starting with /.""" assert controller.can_handle("/", 1) is True assert controller.can_handle("/hel", 4) is True assert controller.can_handle("/help", 5) is True - def test_cannot_handle_non_slash(self, controller): + def test_cannot_handle_non_slash(self, controller: SlashCommandController) -> None: """Does not handle text not starting with /.""" assert controller.can_handle("hello", 5) is False assert controller.can_handle("", 0) is False assert controller.can_handle("test /cmd", 9) is False - def test_filters_commands_by_prefix(self, controller, mock_view): + def test_filters_commands_by_prefix( + self, + controller: SlashCommandController, + mock_view: MagicMock, + ) -> None: """Filters commands based on typed prefix.""" controller.on_text_changed("/hel", 4) @@ -209,7 +215,11 @@ class TestSlashCommandController: suggestions = mock_view.render_completion_suggestions.call_args[0][0] assert any("/help" in s[0] for s in suggestions) - def test_shows_all_commands_on_slash_only(self, controller, mock_view): + def test_shows_all_commands_on_slash_only( + self, + controller: SlashCommandController, + mock_view: MagicMock, + ) -> None: """Shows all commands when just / is typed.""" controller.on_text_changed("/", 1) @@ -217,7 +227,11 @@ class TestSlashCommandController: suggestions = mock_view.render_completion_suggestions.call_args[0][0] assert len(suggestions) == len(SLASH_COMMANDS) - def test_clears_on_no_match(self, controller, mock_view): + def test_clears_on_no_match( + self, + controller: SlashCommandController, + mock_view: MagicMock, + ) -> None: """Clears suggestions when no commands match after having suggestions.""" # First get some suggestions controller.on_text_changed("/h", 2) @@ -227,7 +241,11 @@ class TestSlashCommandController: controller.on_text_changed("/xyz", 4) mock_view.clear_completion_suggestions.assert_called() - def test_reset_clears_state(self, controller, mock_view): + def test_reset_clears_state( + self, + controller: SlashCommandController, + mock_view: MagicMock, + ) -> None: """Reset clears suggestions and state.""" controller.on_text_changed("/h", 2) controller.reset() @@ -239,41 +257,41 @@ class TestFuzzyFileControllerCanHandle: """Tests for FuzzyFileController.can_handle method.""" @pytest.fixture - def mock_view(self): + def mock_view(self) -> MagicMock: """Create a mock CompletionView.""" return MagicMock() @pytest.fixture - def controller(self, mock_view, tmp_path): + def controller(self, mock_view: MagicMock, tmp_path: Path) -> FuzzyFileController: """Create a FuzzyFileController.""" return FuzzyFileController(mock_view, cwd=tmp_path) - def test_handles_at_symbol(self, controller): + def test_handles_at_symbol(self, controller: FuzzyFileController) -> None: """Handles text with @ symbol.""" assert controller.can_handle("@", 1) is True assert controller.can_handle("@file", 5) is True assert controller.can_handle("look at @src/main.py", 20) is True - def test_handles_at_mid_text(self, controller): + def test_handles_at_mid_text(self, controller: FuzzyFileController) -> None: """Handles @ in middle of text.""" assert controller.can_handle("check @file", 11) is True assert controller.can_handle("see @", 5) is True - def test_no_handle_without_at(self, controller): + def test_no_handle_without_at(self, controller: FuzzyFileController) -> None: """Does not handle text without @.""" assert controller.can_handle("hello", 5) is False assert controller.can_handle("", 0) is False - def test_no_handle_at_after_cursor(self, controller): + def test_no_handle_at_after_cursor(self, controller: FuzzyFileController) -> None: """Does not handle @ that's after cursor position.""" assert controller.can_handle("hello @file", 5) is False - def test_no_handle_space_after_at(self, controller): + def test_no_handle_space_after_at(self, controller: FuzzyFileController) -> None: """Does not handle @ followed by space before cursor.""" assert controller.can_handle("@ file", 6) is False assert controller.can_handle("@file name", 10) is False - def test_invalid_cursor_positions(self, controller): + def test_invalid_cursor_positions(self, controller: FuzzyFileController) -> None: """Handles invalid cursor positions gracefully.""" assert controller.can_handle("@file", 0) is False assert controller.can_handle("@file", -1) is False @@ -284,35 +302,35 @@ class TestMultiCompletionManager: """Tests for MultiCompletionManager.""" @pytest.fixture - def mock_view(self): + def mock_view(self) -> MagicMock: """Create a mock CompletionView.""" return MagicMock() @pytest.fixture - def manager(self, mock_view, tmp_path): + def manager(self, mock_view: MagicMock, tmp_path: Path) -> MultiCompletionManager: """Create a MultiCompletionManager with both controllers.""" slash_ctrl = SlashCommandController(SLASH_COMMANDS, mock_view) file_ctrl = FuzzyFileController(mock_view, cwd=tmp_path) return MultiCompletionManager([slash_ctrl, file_ctrl]) - def test_activates_slash_controller_for_slash(self, manager): + def test_activates_slash_controller_for_slash(self, manager: MultiCompletionManager) -> None: """Activates slash controller for / prefix.""" manager.on_text_changed("/help", 5) assert manager._active is not None assert isinstance(manager._active, SlashCommandController) - def test_activates_file_controller_for_at(self, manager): + def test_activates_file_controller_for_at(self, manager: MultiCompletionManager) -> None: """Activates file controller for @ prefix.""" manager.on_text_changed("@file", 5) assert manager._active is not None assert isinstance(manager._active, FuzzyFileController) - def test_no_active_for_plain_text(self, manager): + def test_no_active_for_plain_text(self, manager: MultiCompletionManager) -> None: """No controller active for plain text.""" manager.on_text_changed("hello world", 11) assert manager._active is None - def test_switches_controllers(self, manager): + def test_switches_controllers(self, manager: MultiCompletionManager) -> None: """Switches between controllers as input changes.""" manager.on_text_changed("/cmd", 4) assert isinstance(manager._active, SlashCommandController) @@ -320,7 +338,7 @@ class TestMultiCompletionManager: manager.on_text_changed("@file", 5) assert isinstance(manager._active, FuzzyFileController) - def test_reset_clears_active(self, manager): + def test_reset_clears_active(self, manager: MultiCompletionManager) -> None: """Reset clears active controller.""" manager.on_text_changed("/cmd", 4) manager.reset() diff --git a/deepagents_sourcecode/libs/deepagents-cli/tests/unit_tests/test_end_to_end.py b/deepagents_sourcecode/libs/deepagents-cli/tests/unit_tests/test_end_to_end.py index 86267fc..3a7c010 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/tests/unit_tests/test_end_to_end.py +++ b/deepagents_sourcecode/libs/deepagents-cli/tests/unit_tests/test_end_to_end.py @@ -29,10 +29,10 @@ class FixedGenericFakeChatModel(GenericFakeChatModel): def bind_tools( self, - tools: Sequence[dict[str, Any] | type | Callable | BaseTool], + _tools: Sequence[dict[str, Any] | type | Callable | BaseTool], *, - tool_choice: str | None = None, - **kwargs: Any, + _tool_choice: str | None = None, + **_kwargs: Any, ) -> Runnable[LanguageModelInput, AIMessage]: """Override bind_tools to return self.""" return self @@ -65,8 +65,9 @@ def mock_settings(tmp_path: Path, assistant_id: str = "test-agent") -> Generator mock_settings_obj.ensure_user_skills_dir.return_value = skills_dir mock_settings_obj.get_project_skills_dir.return_value = None - # Mock methods that get called during agent execution to return real Path objects - # This prevents MagicMock objects from being stored in state (which would fail serialization) + # Mock methods that get called during agent execution to return real Path objects. + # This prevents MagicMock objects from being stored in state + # (which would fail serialization). def get_user_agent_md_path(agent_id: str) -> Path: return tmp_path / "agents" / agent_id / "agent.md" @@ -114,7 +115,7 @@ class TestDeepAgentsCLIEndToEnd: ) # Create a CLI agent with the fake model - agent, backend = create_cli_agent( + agent, _ = create_cli_agent( model=model, assistant_id="test-agent", tools=[], @@ -168,7 +169,7 @@ class TestDeepAgentsCLIEndToEnd: ) # Create a CLI agent with the fake model and sample_tool - agent, backend = create_cli_agent( + agent, _ = create_cli_agent( model=model, assistant_id="test-agent", tools=[sample_tool], @@ -224,7 +225,7 @@ class TestDeepAgentsCLIEndToEnd: ) # Create a CLI agent with the fake model - agent, backend = create_cli_agent( + agent, _ = create_cli_agent( model=model, assistant_id="test-agent", tools=[], @@ -284,7 +285,7 @@ class TestDeepAgentsCLIEndToEnd: ) # Create a CLI agent with the fake model and sample_tool - agent, backend = create_cli_agent( + agent, _ = create_cli_agent( model=model, assistant_id="test-agent", tools=[sample_tool], @@ -325,7 +326,7 @@ class TestDeepAgentsCLIEndToEnd: ) # Create a CLI agent - agent, backend = create_cli_agent( + _, backend = create_cli_agent( model=model, assistant_id="test-agent", tools=[], diff --git a/deepagents_sourcecode/libs/deepagents-cli/tests/unit_tests/test_sessions.py b/deepagents_sourcecode/libs/deepagents-cli/tests/unit_tests/test_sessions.py index 3f6ac8f..e69bd85 100644 --- a/deepagents_sourcecode/libs/deepagents-cli/tests/unit_tests/test_sessions.py +++ b/deepagents_sourcecode/libs/deepagents-cli/tests/unit_tests/test_sessions.py @@ -3,6 +3,7 @@ import asyncio import json import sqlite3 +from pathlib import Path from unittest.mock import patch import pytest @@ -34,7 +35,7 @@ class TestThreadFunctions: """Tests for thread query functions.""" @pytest.fixture - def temp_db(self, tmp_path): + def temp_db(self, tmp_path: Path) -> Path: """Create a temporary database with test data.""" db_path = tmp_path / "test_sessions.db" @@ -81,7 +82,8 @@ class TestThreadFunctions: for tid, agent, updated in threads: metadata = json.dumps({"agent_name": agent, "updated_at": updated}) conn.execute( - "INSERT INTO checkpoints (thread_id, checkpoint_ns, checkpoint_id, metadata) VALUES (?, '', ?, ?)", + "INSERT INTO checkpoints (thread_id, checkpoint_ns, checkpoint_id, metadata) " + "VALUES (?, '', ?, ?)", (tid, f"cp_{tid}", metadata), ) @@ -90,7 +92,7 @@ class TestThreadFunctions: return db_path - def test_list_threads_empty(self, tmp_path): + def test_list_threads_empty(self, tmp_path: Path) -> None: """List returns empty when no threads exist.""" db_path = tmp_path / "empty.db" # Create empty db with table structure @@ -110,38 +112,38 @@ class TestThreadFunctions: threads = asyncio.run(sessions.list_threads()) assert threads == [] - def test_list_threads(self, temp_db): + def test_list_threads(self, temp_db: Path) -> None: """List returns all threads.""" with patch.object(sessions, "get_db_path", return_value=temp_db): threads = asyncio.run(sessions.list_threads()) assert len(threads) == 3 - def test_list_threads_filter_by_agent(self, temp_db): + def test_list_threads_filter_by_agent(self, temp_db: Path) -> None: """List filters by agent name.""" with patch.object(sessions, "get_db_path", return_value=temp_db): threads = asyncio.run(sessions.list_threads(agent_name="agent1")) assert len(threads) == 2 assert all(t["agent_name"] == "agent1" for t in threads) - def test_list_threads_limit(self, temp_db): + def test_list_threads_limit(self, temp_db: Path) -> None: """List respects limit.""" with patch.object(sessions, "get_db_path", return_value=temp_db): threads = asyncio.run(sessions.list_threads(limit=2)) assert len(threads) == 2 - def test_get_most_recent(self, temp_db): + def test_get_most_recent(self, temp_db: Path) -> None: """Get most recent returns latest thread.""" with patch.object(sessions, "get_db_path", return_value=temp_db): tid = asyncio.run(sessions.get_most_recent()) assert tid is not None - def test_get_most_recent_filter(self, temp_db): + def test_get_most_recent_filter(self, temp_db: Path) -> None: """Get most recent filters by agent.""" with patch.object(sessions, "get_db_path", return_value=temp_db): tid = asyncio.run(sessions.get_most_recent(agent_name="agent2")) assert tid == "thread2" - def test_get_most_recent_empty(self, tmp_path): + def test_get_most_recent_empty(self, tmp_path: Path) -> None: """Get most recent returns None when empty.""" db_path = tmp_path / "empty.db" # Create empty db with table structure @@ -161,36 +163,36 @@ class TestThreadFunctions: tid = asyncio.run(sessions.get_most_recent()) assert tid is None - def test_thread_exists(self, temp_db): + def test_thread_exists(self, temp_db: Path) -> None: """Thread exists returns True for existing thread.""" with patch.object(sessions, "get_db_path", return_value=temp_db): assert asyncio.run(sessions.thread_exists("thread1")) is True - def test_thread_not_exists(self, temp_db): + def test_thread_not_exists(self, temp_db: Path) -> None: """Thread exists returns False for non-existing thread.""" with patch.object(sessions, "get_db_path", return_value=temp_db): assert asyncio.run(sessions.thread_exists("nonexistent")) is False - def test_get_thread_agent(self, temp_db): + def test_get_thread_agent(self, temp_db: Path) -> None: """Get thread agent returns correct agent name.""" with patch.object(sessions, "get_db_path", return_value=temp_db): agent = asyncio.run(sessions.get_thread_agent("thread1")) assert agent == "agent1" - def test_get_thread_agent_not_found(self, temp_db): + def test_get_thread_agent_not_found(self, temp_db: Path) -> None: """Get thread agent returns None for non-existing thread.""" with patch.object(sessions, "get_db_path", return_value=temp_db): agent = asyncio.run(sessions.get_thread_agent("nonexistent")) assert agent is None - def test_delete_thread(self, temp_db): + def test_delete_thread(self, temp_db: Path) -> None: """Delete thread removes thread.""" with patch.object(sessions, "get_db_path", return_value=temp_db): result = asyncio.run(sessions.delete_thread("thread1")) assert result is True assert asyncio.run(sessions.thread_exists("thread1")) is False - def test_delete_thread_not_found(self, temp_db): + def test_delete_thread_not_found(self, temp_db: Path) -> None: """Delete thread returns False for non-existing thread.""" with patch.object(sessions, "get_db_path", return_value=temp_db): result = asyncio.run(sessions.delete_thread("nonexistent")) @@ -200,10 +202,10 @@ class TestThreadFunctions: class TestGetCheckpointer: """Tests for get_checkpointer async context manager.""" - def test_returns_async_sqlite_saver(self, tmp_path): + def test_returns_async_sqlite_saver(self, tmp_path: Path) -> None: """Get checkpointer returns AsyncSqliteSaver.""" - async def _test(): + async def _test() -> None: db_path = tmp_path / "test.db" with patch.object(sessions, "get_db_path", return_value=db_path): async with sessions.get_checkpointer() as cp: diff --git a/deepagents_sourcecode/libs/deepagents/deepagents/__init__.py b/deepagents_sourcecode/libs/deepagents/deepagents/__init__.py index 01adf7b..34dcdcf 100644 --- a/deepagents_sourcecode/libs/deepagents/deepagents/__init__.py +++ b/deepagents_sourcecode/libs/deepagents/deepagents/__init__.py @@ -1,4 +1,4 @@ -"""DeepAgents package.""" +"""DeepAgents 패키지.""" from deepagents.graph import create_deep_agent from deepagents.middleware.filesystem import FilesystemMiddleware diff --git a/deepagents_sourcecode/libs/deepagents/deepagents/backends/__init__.py b/deepagents_sourcecode/libs/deepagents/deepagents/backends/__init__.py index 77efdea..d8020b6 100644 --- a/deepagents_sourcecode/libs/deepagents/deepagents/backends/__init__.py +++ b/deepagents_sourcecode/libs/deepagents/deepagents/backends/__init__.py @@ -1,4 +1,4 @@ -"""Memory backends for pluggable file storage.""" +"""플러그인 가능한 파일 저장을 위한 메모리 백엔드입니다.""" from deepagents.backends.composite import CompositeBackend from deepagents.backends.filesystem import FilesystemBackend diff --git a/deepagents_sourcecode/libs/deepagents/deepagents/backends/composite.py b/deepagents_sourcecode/libs/deepagents/deepagents/backends/composite.py index c6523ea..0150e1b 100644 --- a/deepagents_sourcecode/libs/deepagents/deepagents/backends/composite.py +++ b/deepagents_sourcecode/libs/deepagents/deepagents/backends/composite.py @@ -1,17 +1,20 @@ -"""Composite backend that routes file operations by path prefix. +"""경로 prefix에 따라 파일 작업을 라우팅하는 합성(Composite) 백엔드입니다. -Routes operations to different backends based on path prefixes. Use this when you -need different storage strategies for different paths (e.g., state for temp files, -persistent store for memories). +경로(prefix) 규칙에 따라 서로 다른 백엔드로 파일 작업을 위임합니다. 예를 들어, +임시 파일은 `StateBackend`에, 장기 메모리는 `StoreBackend`에 저장하는 식으로 +경로별 저장 전략을 분리하고 싶을 때 사용합니다. -Examples: +예시: ```python from deepagents.backends.composite import CompositeBackend from deepagents.backends.state import StateBackend from deepagents.backends.store import StoreBackend runtime = make_runtime() - composite = CompositeBackend(default=StateBackend(runtime), routes={"/memories/": StoreBackend(runtime)}) + composite = CompositeBackend( + default=StateBackend(runtime), + routes={"/memories/": StoreBackend(runtime)}, + ) composite.write("/temp.txt", "ephemeral") composite.write("/memories/note.md", "persistent") @@ -35,19 +38,22 @@ from deepagents.backends.state import StateBackend class CompositeBackend(BackendProtocol): - """Routes file operations to different backends by path prefix. + """경로 prefix에 따라 파일 작업을 서로 다른 백엔드로 위임합니다. - Matches paths against route prefixes (longest first) and delegates to the - corresponding backend. Unmatched paths use the default backend. + 경로를 route prefix(길이가 긴 것부터)와 매칭한 후 해당 백엔드로 위임합니다. + 어떤 prefix에도 매칭되지 않으면 `default` 백엔드를 사용합니다. Attributes: - default: Backend for paths that don't match any route. - routes: Map of path prefixes to backends (e.g., {"/memories/": store_backend}). - sorted_routes: Routes sorted by length (longest first) for correct matching. + default: 어떤 route에도 매칭되지 않을 때 사용할 백엔드. + routes: 경로 prefix → 백엔드 매핑(예: `{"/memories/": store_backend}`). + sorted_routes: 올바른 매칭을 위해 길이 기준(내림차순)으로 정렬한 routes. - Examples: + 예시: ```python - composite = CompositeBackend(default=StateBackend(runtime), routes={"/memories/": StoreBackend(runtime), "/cache/": StoreBackend(runtime)}) + composite = CompositeBackend( + default=StateBackend(runtime), + routes={"/memories/": StoreBackend(runtime), "/cache/": StoreBackend(runtime)}, + ) composite.write("/temp.txt", "data") composite.write("/memories/note.txt", "data") @@ -59,37 +65,37 @@ class CompositeBackend(BackendProtocol): default: BackendProtocol | StateBackend, routes: dict[str, BackendProtocol], ) -> None: - """Initialize composite backend. + """합성 백엔드를 초기화합니다. Args: - default: Backend for paths that don't match any route. - routes: Map of path prefixes to backends. Prefixes must start with "/" - and should end with "/" (e.g., "/memories/"). + default: 어떤 route에도 매칭되지 않을 때 사용할 백엔드. + routes: 경로 prefix → 백엔드 매핑. prefix는 반드시 `/`로 시작해야 하며, + 일반적으로 `/`로 끝나도록 지정하는 것을 권장합니다(예: `/memories/`). """ - # Default backend + # 기본(default) 백엔드 self.default = default - # Virtual routes + # 가상(virtual) route 설정 self.routes = routes - # Sort routes by length (longest first) for correct prefix matching + # prefix 매칭이 올바르게 동작하도록 길이 기준(내림차순)으로 정렬 self.sorted_routes = sorted(routes.items(), key=lambda x: len(x[0]), reverse=True) def _get_backend_and_key(self, key: str) -> tuple[BackendProtocol, str]: - """Get backend for path and strip route prefix. + """경로에 맞는 백엔드를 찾고 route prefix를 제거한 경로를 반환합니다. Args: - key: File path to route. + key: 라우팅할 파일 경로. Returns: - Tuple of (backend, stripped_path). The stripped path has the route - prefix removed but keeps the leading slash. + `(backend, stripped_path)` 튜플. `stripped_path`는 route prefix를 제거하되 + 선행 `/`는 유지합니다. """ - # Check routes in order of length (longest first) + # route prefix 길이가 긴 것부터 확인(가장 구체적인 route 우선) for prefix, backend in self.sorted_routes: if key.startswith(prefix): - # Strip full prefix and ensure a leading slash remains - # e.g., "/memories/notes.txt" → "/notes.txt"; "/memories/" → "/" + # prefix를 제거하되, 선행 슬래시를 유지 + # 예: "/memories/notes.txt" → "/notes.txt", "/memories/" → "/" suffix = key[len(prefix) :] stripped_key = f"/{suffix}" if suffix else "/" return backend, stripped_key @@ -97,28 +103,29 @@ class CompositeBackend(BackendProtocol): return self.default, key def ls_info(self, path: str) -> list[FileInfo]: - """List directory contents (non-recursive). + """디렉토리 내용을 나열합니다(비재귀). - If path matches a route, lists only that backend. If path is "/", aggregates - default backend plus virtual route directories. Otherwise lists default backend. + - `path`가 특정 route에 매칭되면 해당 백엔드만 나열합니다. + - `path == "/"`이면 default 백엔드 + 가상 route 디렉토리를 함께 반환합니다. + - 그 외에는 default 백엔드만 나열합니다. Args: - path: Absolute directory path starting with "/". + path: `/`로 시작하는 절대 디렉토리 경로. Returns: - List of FileInfo dicts. Directories have trailing "/" and is_dir=True. - Route prefixes are restored in returned paths. + FileInfo 딕셔너리 리스트. 디렉토리는 `path`가 `/`로 끝나며 `is_dir=True`입니다. + route를 통해 조회된 경우 반환 경로에는 원래의 route prefix가 복원됩니다. - Examples: + 예시: ```python infos = composite.ls_info("/") infos = composite.ls_info("/memories/") ``` """ - # Check if path matches a specific route + # path가 특정 route에 매칭되는지 확인 for route_prefix, backend in self.sorted_routes: if path.startswith(route_prefix.rstrip("/")): - # Query only the matching routed backend + # 매칭된 routed backend만 조회 suffix = path[len(route_prefix) :] search_path = f"/{suffix}" if suffix else "/" infos = backend.ls_info(search_path) @@ -129,12 +136,12 @@ class CompositeBackend(BackendProtocol): prefixed.append(fi) return prefixed - # At root, aggregate default and all routed backends + # 루트에서는 default + 모든 route 디렉토리를 합산 if path == "/": results: list[FileInfo] = [] results.extend(self.default.ls_info(path)) for route_prefix, backend in self.sorted_routes: - # Add the route itself as a directory (e.g., /memories/) + # route 자체를 디렉토리로 추가(예: /memories/) results.append( { "path": route_prefix, @@ -147,15 +154,15 @@ class CompositeBackend(BackendProtocol): results.sort(key=lambda x: x.get("path", "")) return results - # Path doesn't match a route: query only default backend + # 어떤 route에도 매칭되지 않으면 default backend만 조회 return self.default.ls_info(path) async def als_info(self, path: str) -> list[FileInfo]: - """Async version of ls_info.""" - # Check if path matches a specific route + """`ls_info`의 async 버전입니다.""" + # path가 특정 route에 매칭되는지 확인 for route_prefix, backend in self.sorted_routes: if path.startswith(route_prefix.rstrip("/")): - # Query only the matching routed backend + # 매칭된 routed backend만 조회 suffix = path[len(route_prefix) :] search_path = f"/{suffix}" if suffix else "/" infos = await backend.als_info(search_path) @@ -166,12 +173,12 @@ class CompositeBackend(BackendProtocol): prefixed.append(fi) return prefixed - # At root, aggregate default and all routed backends + # 루트에서는 default + 모든 route 디렉토리를 합산 if path == "/": results: list[FileInfo] = [] results.extend(await self.default.als_info(path)) for route_prefix, backend in self.sorted_routes: - # Add the route itself as a directory (e.g., /memories/) + # route 자체를 디렉토리로 추가(예: /memories/) results.append( { "path": route_prefix, @@ -184,7 +191,7 @@ class CompositeBackend(BackendProtocol): results.sort(key=lambda x: x.get("path", "")) return results - # Path doesn't match a route: query only default backend + # 어떤 route에도 매칭되지 않으면 default backend만 조회 return await self.default.als_info(path) def read( @@ -193,15 +200,15 @@ class CompositeBackend(BackendProtocol): offset: int = 0, limit: int = 2000, ) -> str: - """Read file content, routing to appropriate backend. + """파일 내용을 읽습니다(경로에 맞는 백엔드로 라우팅). Args: - file_path: Absolute file path. - offset: Line offset to start reading from (0-indexed). - limit: Maximum number of lines to read. + file_path: 절대 파일 경로. + offset: 읽기 시작 라인 오프셋(0-index). + limit: 최대 읽기 라인 수. Returns: - Formatted file content with line numbers, or error message. + 라인 번호가 포함된 포맷 문자열 또는 오류 메시지. """ backend, stripped_key = self._get_backend_and_key(file_path) return backend.read(stripped_key, offset=offset, limit=limit) @@ -212,7 +219,7 @@ class CompositeBackend(BackendProtocol): offset: int = 0, limit: int = 2000, ) -> str: - """Async version of read.""" + """`read`의 async 버전입니다.""" backend, stripped_key = self._get_backend_and_key(file_path) return await backend.aread(stripped_key, offset=offset, limit=limit) @@ -222,10 +229,12 @@ class CompositeBackend(BackendProtocol): path: str | None = None, glob: str | None = None, ) -> list[GrepMatch] | str: - """Search files for regex pattern. + """파일에서 정규식 패턴을 검색합니다. - Routes to backends based on path: specific route searches one backend, - "/" or None searches all backends, otherwise searches default backend. + `path`에 따라 검색 대상 백엔드를 라우팅합니다. + - 특정 route에 매칭되면 해당 백엔드만 검색 + - `"/"` 또는 `None`이면 default + 모든 route 백엔드를 검색하여 병합 + - 그 외는 default 백엔드만 검색 Args: pattern: Regex pattern to search for. @@ -244,7 +253,7 @@ class CompositeBackend(BackendProtocol): matches = composite.grep_raw("import", path="/", glob="*.py") ``` """ - # If path targets a specific route, search only that backend + # path가 특정 route를 가리키면 해당 백엔드만 검색 for route_prefix, backend in self.sorted_routes: if path is not None and path.startswith(route_prefix.rstrip("/")): search_path = path[len(route_prefix) - 1 :] @@ -253,20 +262,20 @@ class CompositeBackend(BackendProtocol): return raw return [{**m, "path": f"{route_prefix[:-1]}{m['path']}"} for m in raw] - # If path is None or "/", search default and all routed backends and merge - # Otherwise, search only the default backend + # path가 None 또는 "/"이면 default + 모든 route 백엔드를 검색하여 병합 + # 그 외에는 default 백엔드만 검색 if path is None or path == "/": all_matches: list[GrepMatch] = [] raw_default = self.default.grep_raw(pattern, path, glob) # type: ignore[attr-defined] if isinstance(raw_default, str): - # This happens if error occurs + # 에러가 발생하면 문자열 오류 메시지가 반환됩니다. return raw_default all_matches.extend(raw_default) for route_prefix, backend in self.routes.items(): raw = backend.grep_raw(pattern, "/", glob) if isinstance(raw, str): - # This happens if error occurs + # 에러가 발생하면 문자열 오류 메시지가 반환됩니다. return raw all_matches.extend({**m, "path": f"{route_prefix[:-1]}{m['path']}"} for m in raw) @@ -280,11 +289,11 @@ class CompositeBackend(BackendProtocol): path: str | None = None, glob: str | None = None, ) -> list[GrepMatch] | str: - """Async version of grep_raw. + """`grep_raw`의 async 버전입니다. - See grep_raw() for detailed documentation on routing behavior and parameters. + 라우팅 동작과 파라미터에 대한 자세한 설명은 `grep_raw()`를 참고하세요. """ - # If path targets a specific route, search only that backend + # path가 특정 route를 가리키면 해당 백엔드만 검색 for route_prefix, backend in self.sorted_routes: if path is not None and path.startswith(route_prefix.rstrip("/")): search_path = path[len(route_prefix) - 1 :] @@ -293,20 +302,20 @@ class CompositeBackend(BackendProtocol): return raw return [{**m, "path": f"{route_prefix[:-1]}{m['path']}"} for m in raw] - # If path is None or "/", search default and all routed backends and merge - # Otherwise, search only the default backend + # path가 None 또는 "/"이면 default + 모든 route 백엔드를 검색하여 병합 + # 그 외에는 default 백엔드만 검색 if path is None or path == "/": all_matches: list[GrepMatch] = [] raw_default = await self.default.agrep_raw(pattern, path, glob) # type: ignore[attr-defined] if isinstance(raw_default, str): - # This happens if error occurs + # 에러가 발생하면 문자열 오류 메시지가 반환됩니다. return raw_default all_matches.extend(raw_default) for route_prefix, backend in self.routes.items(): raw = await backend.agrep_raw(pattern, "/", glob) if isinstance(raw, str): - # This happens if error occurs + # 에러가 발생하면 문자열 오류 메시지가 반환됩니다. return raw all_matches.extend({**m, "path": f"{route_prefix[:-1]}{m['path']}"} for m in raw) @@ -336,24 +345,24 @@ class CompositeBackend(BackendProtocol): return results async def aglob_info(self, pattern: str, path: str = "/") -> list[FileInfo]: - """Async version of glob_info.""" + """`glob_info`의 async 버전입니다.""" results: list[FileInfo] = [] - # Route based on path, not pattern + # pattern이 아니라 path 기준으로 라우팅 for route_prefix, backend in self.sorted_routes: if path.startswith(route_prefix.rstrip("/")): search_path = path[len(route_prefix) - 1 :] infos = await backend.aglob_info(pattern, search_path if search_path else "/") return [{**fi, "path": f"{route_prefix[:-1]}{fi['path']}"} for fi in infos] - # Path doesn't match any specific route - search default backend AND all routed backends + # 어떤 route에도 매칭되지 않으면 default + 모든 route 백엔드를 검색 results.extend(await self.default.aglob_info(pattern, path)) for route_prefix, backend in self.routes.items(): infos = await backend.aglob_info(pattern, "/") results.extend({**fi, "path": f"{route_prefix[:-1]}{fi['path']}"} for fi in infos) - # Deterministic ordering + # deterministic ordering results.sort(key=lambda x: x.get("path", "")) return results @@ -362,7 +371,7 @@ class CompositeBackend(BackendProtocol): file_path: str, content: str, ) -> WriteResult: - """Create a new file, routing to appropriate backend. + """새 파일을 생성합니다(경로에 맞는 백엔드로 라우팅). Args: file_path: Absolute file path. @@ -373,7 +382,8 @@ class CompositeBackend(BackendProtocol): """ backend, stripped_key = self._get_backend_and_key(file_path) res = backend.write(stripped_key, content) - # If this is a state-backed update and default has state, merge so listings reflect changes + # state-backed 업데이트(그리고 default가 state를 갖는 경우)면, + # listing이 변경을 반영하도록 default state에도 병합합니다. if res.files_update: try: runtime = getattr(self.default, "runtime", None) @@ -391,10 +401,11 @@ class CompositeBackend(BackendProtocol): file_path: str, content: str, ) -> WriteResult: - """Async version of write.""" + """`write`의 async 버전입니다.""" backend, stripped_key = self._get_backend_and_key(file_path) res = await backend.awrite(stripped_key, content) - # If this is a state-backed update and default has state, merge so listings reflect changes + # state-backed 업데이트(그리고 default가 state를 갖는 경우)면, + # listing이 변경을 반영하도록 default state에도 병합합니다. if res.files_update: try: runtime = getattr(self.default, "runtime", None) @@ -414,7 +425,7 @@ class CompositeBackend(BackendProtocol): new_string: str, replace_all: bool = False, ) -> EditResult: - """Edit a file, routing to appropriate backend. + """파일을 편집합니다(경로에 맞는 백엔드로 라우팅). Args: file_path: Absolute file path. @@ -446,7 +457,7 @@ class CompositeBackend(BackendProtocol): new_string: str, replace_all: bool = False, ) -> EditResult: - """Async version of edit.""" + """`edit`의 async 버전입니다.""" backend, stripped_key = self._get_backend_and_key(file_path) res = await backend.aedit(stripped_key, old_string, new_string, replace_all=replace_all) if res.files_update: @@ -465,7 +476,7 @@ class CompositeBackend(BackendProtocol): self, command: str, ) -> ExecuteResponse: - """Execute shell command via default backend. + """Default 백엔드를 통해 셸 커맨드를 실행합니다. Args: command: Shell command to execute. @@ -486,8 +497,8 @@ class CompositeBackend(BackendProtocol): if isinstance(self.default, SandboxBackendProtocol): return self.default.execute(command) - # This shouldn't be reached if the runtime check in the execute tool works correctly, - # but we include it as a safety fallback. + # execute 도구의 런타임 체크가 제대로 동작한다면 여기에 도달하지 않아야 하지만, + # 안전장치(fallback)로 예외를 둡니다. raise NotImplementedError( "Default backend doesn't support command execution (SandboxBackendProtocol). " "To enable execution, provide a default backend that implements SandboxBackendProtocol." @@ -497,19 +508,19 @@ class CompositeBackend(BackendProtocol): self, command: str, ) -> ExecuteResponse: - """Async version of execute.""" + """`execute`의 async 버전입니다.""" if isinstance(self.default, SandboxBackendProtocol): return await self.default.aexecute(command) - # This shouldn't be reached if the runtime check in the execute tool works correctly, - # but we include it as a safety fallback. + # execute 도구의 런타임 체크가 제대로 동작한다면 여기에 도달하지 않아야 하지만, + # 안전장치(fallback)로 예외를 둡니다. raise NotImplementedError( "Default backend doesn't support command execution (SandboxBackendProtocol). " "To enable execution, provide a default backend that implements SandboxBackendProtocol." ) def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]: - """Upload multiple files, batching by backend for efficiency. + """여러 파일을 업로드합니다(백엔드별로 배치 처리하여 효율화). Groups files by their target backend, calls each backend's upload_files once with all files for that backend, then merges results in original order. @@ -521,10 +532,10 @@ class CompositeBackend(BackendProtocol): List of FileUploadResponse objects, one per input file. Response order matches input order. """ - # Pre-allocate result list + # 결과 리스트를 미리 할당 results: list[FileUploadResponse | None] = [None] * len(files) - # Group files by backend, tracking original indices + # 원래 인덱스를 유지하면서 백엔드별로 파일을 그룹화 from collections import defaultdict backend_batches: dict[BackendProtocol, list[tuple[int, str, bytes]]] = defaultdict(list) @@ -533,16 +544,16 @@ class CompositeBackend(BackendProtocol): backend, stripped_path = self._get_backend_and_key(path) backend_batches[backend].append((idx, stripped_path, content)) - # Process each backend's batch + # 백엔드별 배치를 처리 for backend, batch in backend_batches.items(): - # Extract data for backend call + # 백엔드 호출에 필요한 데이터로 분해 indices, stripped_paths, contents = zip(*batch, strict=False) batch_files = list(zip(stripped_paths, contents, strict=False)) - # Call backend once with all its files + # 해당 백엔드로 1회 호출(배치) batch_responses = backend.upload_files(batch_files) - # Place responses at original indices with original paths + # 원래 경로/인덱스 위치에 응답을 채웁니다. for i, orig_idx in enumerate(indices): results[orig_idx] = FileUploadResponse( path=files[orig_idx][0], # Original path @@ -552,27 +563,27 @@ class CompositeBackend(BackendProtocol): return results # type: ignore[return-value] async def aupload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]: - """Async version of upload_files.""" - # Pre-allocate result list + """`upload_files`의 async 버전입니다.""" + # 결과 리스트를 미리 할당 results: list[FileUploadResponse | None] = [None] * len(files) - # Group files by backend, tracking original indices + # 원래 인덱스를 유지하면서 백엔드별로 파일을 그룹화 backend_batches: dict[BackendProtocol, list[tuple[int, str, bytes]]] = defaultdict(list) for idx, (path, content) in enumerate(files): backend, stripped_path = self._get_backend_and_key(path) backend_batches[backend].append((idx, stripped_path, content)) - # Process each backend's batch + # 백엔드별 배치를 처리 for backend, batch in backend_batches.items(): - # Extract data for backend call + # 백엔드 호출에 필요한 데이터로 분해 indices, stripped_paths, contents = zip(*batch, strict=False) batch_files = list(zip(stripped_paths, contents, strict=False)) - # Call backend once with all its files + # 해당 백엔드로 1회 호출(배치) batch_responses = await backend.aupload_files(batch_files) - # Place responses at original indices with original paths + # 원래 경로/인덱스 위치에 응답을 채웁니다. for i, orig_idx in enumerate(indices): results[orig_idx] = FileUploadResponse( path=files[orig_idx][0], # Original path @@ -582,7 +593,7 @@ class CompositeBackend(BackendProtocol): return results # type: ignore[return-value] def download_files(self, paths: list[str]) -> list[FileDownloadResponse]: - """Download multiple files, batching by backend for efficiency. + """여러 파일을 다운로드합니다(백엔드별로 배치 처리하여 효율화). Groups paths by their target backend, calls each backend's download_files once with all paths for that backend, then merges results in original order. @@ -594,7 +605,7 @@ class CompositeBackend(BackendProtocol): List of FileDownloadResponse objects, one per input path. Response order matches input order. """ - # Pre-allocate result list + # 결과 리스트를 미리 할당 results: list[FileDownloadResponse | None] = [None] * len(paths) backend_batches: dict[BackendProtocol, list[tuple[int, str]]] = defaultdict(list) @@ -603,15 +614,15 @@ class CompositeBackend(BackendProtocol): backend, stripped_path = self._get_backend_and_key(path) backend_batches[backend].append((idx, stripped_path)) - # Process each backend's batch + # 백엔드별 배치를 처리 for backend, batch in backend_batches.items(): - # Extract data for backend call + # 백엔드 호출에 필요한 데이터로 분해 indices, stripped_paths = zip(*batch, strict=False) - # Call backend once with all its paths + # 해당 백엔드로 1회 호출(배치) batch_responses = backend.download_files(list(stripped_paths)) - # Place responses at original indices with original paths + # 원래 경로/인덱스 위치에 응답을 채웁니다. for i, orig_idx in enumerate(indices): results[orig_idx] = FileDownloadResponse( path=paths[orig_idx], # Original path @@ -622,8 +633,8 @@ class CompositeBackend(BackendProtocol): return results # type: ignore[return-value] async def adownload_files(self, paths: list[str]) -> list[FileDownloadResponse]: - """Async version of download_files.""" - # Pre-allocate result list + """`download_files`의 async 버전입니다.""" + # 결과 리스트를 미리 할당 results: list[FileDownloadResponse | None] = [None] * len(paths) backend_batches: dict[BackendProtocol, list[tuple[int, str]]] = defaultdict(list) @@ -632,15 +643,15 @@ class CompositeBackend(BackendProtocol): backend, stripped_path = self._get_backend_and_key(path) backend_batches[backend].append((idx, stripped_path)) - # Process each backend's batch + # 백엔드별 배치를 처리 for backend, batch in backend_batches.items(): - # Extract data for backend call + # 백엔드 호출에 필요한 데이터로 분해 indices, stripped_paths = zip(*batch, strict=False) - # Call backend once with all its paths + # 해당 백엔드로 1회 호출(배치) batch_responses = await backend.adownload_files(list(stripped_paths)) - # Place responses at original indices with original paths + # 원래 경로/인덱스 위치에 응답을 채웁니다. for i, orig_idx in enumerate(indices): results[orig_idx] = FileDownloadResponse( path=paths[orig_idx], # Original path diff --git a/deepagents_sourcecode/libs/deepagents/deepagents/backends/filesystem.py b/deepagents_sourcecode/libs/deepagents/deepagents/backends/filesystem.py index 48156f7..74a41f5 100644 --- a/deepagents_sourcecode/libs/deepagents/deepagents/backends/filesystem.py +++ b/deepagents_sourcecode/libs/deepagents/deepagents/backends/filesystem.py @@ -1,10 +1,10 @@ -"""FilesystemBackend: Read and write files directly from the filesystem. +"""FilesystemBackend: 파일 시스템에서 직접 파일을 읽고 씁니다. -Security and search upgrades: -- Secure path resolution with root containment when in virtual_mode (sandboxed to cwd) -- Prevent symlink-following on file I/O using O_NOFOLLOW when available -- Ripgrep-powered grep with JSON parsing, plus Python fallback with regex - and optional glob include filtering, while preserving virtual path behavior +보안 및 검색 기능 개선: +- virtual_mode(sandboxed to cwd)에서 루트 컨테인먼트를 통한 보안 경로 해결 +- 사용 가능한 경우 O_NOFOLLOW를 사용하여 파일 I/O에서 심볼로우 방지 +- JSON 파싱을 지원하는 Ripgrep 기반 grep, 정규식을 사용하는 Python 폴백 및 + 선택적 glob 포함 필터링, 가상 경로 동작 유지 """ import json @@ -33,11 +33,11 @@ from deepagents.backends.utils import ( class FilesystemBackend(BackendProtocol): - """Backend that reads and writes files directly from the filesystem. + """로컬 파일 시스템에서 직접 파일을 읽고/쓰는 백엔드입니다. - Files are accessed using their actual filesystem paths. Relative paths are - resolved relative to the current working directory. Content is read/written - as plain text, and metadata (timestamps) are derived from filesystem stats. + 파일은 실제 파일 시스템 경로로 접근합니다. 상대 경로는 현재 작업 디렉토리(`cwd`) 기준으로 + 해석되며, 콘텐츠는 일반 텍스트로 읽고/씁니다. 메타데이터(타임스탬프 등)는 파일 시스템 + stat 정보를 기반으로 계산합니다. """ def __init__( @@ -46,7 +46,7 @@ class FilesystemBackend(BackendProtocol): virtual_mode: bool = False, max_file_size_mb: int = 10, ) -> None: - """Initialize filesystem backend. + """파일 시스템 백엔드를 초기화합니다. Args: root_dir: Optional root directory for file operations. If provided, @@ -58,7 +58,7 @@ class FilesystemBackend(BackendProtocol): self.max_file_size_bytes = max_file_size_mb * 1024 * 1024 def _resolve_path(self, key: str) -> Path: - """Resolve a file path with security checks. + """보안 검사를 포함해 파일 경로를 해석(resolve)합니다. When virtual_mode=True, treat incoming paths as virtual absolute paths under self.cwd, disallow traversal (.., ~) and ensure resolved path stays within root. @@ -88,7 +88,7 @@ class FilesystemBackend(BackendProtocol): return (self.cwd / path).resolve() def ls_info(self, path: str) -> list[FileInfo]: - """List files and directories in the specified directory (non-recursive). + """지정한 디렉토리 바로 아래의 파일/폴더를 나열합니다(비재귀). Args: path: Absolute directory path to list files from. @@ -103,12 +103,12 @@ class FilesystemBackend(BackendProtocol): results: list[FileInfo] = [] - # Convert cwd to string for comparison + # 비교를 위해 cwd를 문자열로 변환 cwd_str = str(self.cwd) if not cwd_str.endswith("/"): cwd_str += "/" - # List only direct children (non-recursive) + # 직계 자식만 나열(비재귀) try: for child_path in dir_path.iterdir(): try: @@ -120,7 +120,7 @@ class FilesystemBackend(BackendProtocol): abs_path = str(child_path) if not self.virtual_mode: - # Non-virtual mode: use absolute paths + # non-virtual 모드: 절대 경로를 사용 if is_file: try: st = child_path.stat() @@ -148,14 +148,14 @@ class FilesystemBackend(BackendProtocol): except OSError: results.append({"path": abs_path + "/", "is_dir": True}) else: - # Virtual mode: strip cwd prefix + # virtual 모드: cwd prefix를 제거하여 가상 경로로 변환 if abs_path.startswith(cwd_str): relative_path = abs_path[len(cwd_str) :] elif abs_path.startswith(str(self.cwd)): - # Handle case where cwd doesn't end with / + # cwd가 `/`로 끝나지 않는 케이스 보정 relative_path = abs_path[len(str(self.cwd)) :].lstrip("/") else: - # Path is outside cwd, return as-is or skip + # cwd 밖의 경로: 그대로 반환하거나 스킵 relative_path = abs_path virt_path = "/" + relative_path @@ -189,7 +189,7 @@ class FilesystemBackend(BackendProtocol): except (OSError, PermissionError): pass - # Keep deterministic order by path + # 경로 기준으로 deterministic order 유지 results.sort(key=lambda x: x.get("path", "")) return results @@ -199,7 +199,7 @@ class FilesystemBackend(BackendProtocol): offset: int = 0, limit: int = 2000, ) -> str: - """Read file content with line numbers. + """파일을 읽어 라인 번호가 포함된 문자열로 반환합니다. Args: file_path: Absolute or relative file path. @@ -215,7 +215,7 @@ class FilesystemBackend(BackendProtocol): return f"Error: File '{file_path}' not found" try: - # Open with O_NOFOLLOW where available to avoid symlink traversal + # 가능하면 O_NOFOLLOW로 열어 심볼릭 링크를 통한 우회를 방지 fd = os.open(resolved_path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) with os.fdopen(fd, "r", encoding="utf-8") as f: content = f.read() @@ -241,8 +241,9 @@ class FilesystemBackend(BackendProtocol): file_path: str, content: str, ) -> WriteResult: - """Create a new file with content. - Returns WriteResult. External storage sets files_update=None. + """새 파일을 생성하고 내용을 씁니다. + + `WriteResult`를 반환합니다. 외부 스토리지 백엔드는 `files_update=None`을 사용합니다. """ resolved_path = self._resolve_path(file_path) @@ -250,10 +251,10 @@ class FilesystemBackend(BackendProtocol): return WriteResult(error=f"Cannot write to {file_path} because it already exists. Read and then make an edit, or write to a new path.") try: - # Create parent directories if needed + # 필요하면 상위 디렉토리를 생성 resolved_path.parent.mkdir(parents=True, exist_ok=True) - # Prefer O_NOFOLLOW to avoid writing through symlinks + # 가능하면 O_NOFOLLOW를 사용해 심볼릭 링크를 통한 쓰기 우회를 방지 flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC if hasattr(os, "O_NOFOLLOW"): flags |= os.O_NOFOLLOW @@ -272,8 +273,9 @@ class FilesystemBackend(BackendProtocol): new_string: str, replace_all: bool = False, ) -> EditResult: - """Edit a file by replacing string occurrences. - Returns EditResult. External storage sets files_update=None. + """파일 내 문자열을 치환하여 편집합니다. + + `EditResult`를 반환합니다. 외부 스토리지 백엔드는 `files_update=None`을 사용합니다. """ resolved_path = self._resolve_path(file_path) @@ -281,7 +283,7 @@ class FilesystemBackend(BackendProtocol): return EditResult(error=f"Error: File '{file_path}' not found") try: - # Read securely + # 안전하게 읽기 fd = os.open(resolved_path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) with os.fdopen(fd, "r", encoding="utf-8") as f: content = f.read() @@ -293,7 +295,7 @@ class FilesystemBackend(BackendProtocol): new_content, occurrences = result - # Write securely + # 안전하게 쓰기 flags = os.O_WRONLY | os.O_TRUNC if hasattr(os, "O_NOFOLLOW"): flags |= os.O_NOFOLLOW @@ -311,7 +313,7 @@ class FilesystemBackend(BackendProtocol): path: str | None = None, glob: str | None = None, ) -> list[GrepMatch] | str: - # Validate regex + # 정규식 검증 try: re.compile(pattern) except re.error as e: @@ -480,7 +482,7 @@ class FilesystemBackend(BackendProtocol): return results def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]: - """Upload multiple files to the filesystem. + """여러 파일을 파일 시스템에 업로드합니다. Args: files: List of (path, content) tuples where content is bytes. @@ -494,7 +496,7 @@ class FilesystemBackend(BackendProtocol): try: resolved_path = self._resolve_path(path) - # Create parent directories if needed + # 필요하면 상위 디렉토리를 생성 resolved_path.parent.mkdir(parents=True, exist_ok=True) flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC @@ -510,17 +512,18 @@ class FilesystemBackend(BackendProtocol): except PermissionError: responses.append(FileUploadResponse(path=path, error="permission_denied")) except (ValueError, OSError) as e: - # ValueError from _resolve_path for path traversal, OSError for other file errors + # _resolve_path에서 경로 탐색(path traversal)일 때 ValueError, + # 그 외 파일 오류는 OSError가 발생할 수 있습니다. if isinstance(e, ValueError) or "invalid" in str(e).lower(): responses.append(FileUploadResponse(path=path, error="invalid_path")) else: - # Generic error fallback + # 일반적인 fallback responses.append(FileUploadResponse(path=path, error="invalid_path")) return responses def download_files(self, paths: list[str]) -> list[FileDownloadResponse]: - """Download multiple files from the filesystem. + """여러 파일을 파일 시스템에서 다운로드합니다. Args: paths: List of file paths to download. @@ -532,8 +535,7 @@ class FilesystemBackend(BackendProtocol): for path in paths: try: resolved_path = self._resolve_path(path) - # Use flags to optionally prevent symlink following if - # supported by the OS + # OS가 지원하면, 플래그로 심볼릭 링크 추적을 방지합니다. fd = os.open(resolved_path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) with os.fdopen(fd, "rb") as f: content = f.read() diff --git a/deepagents_sourcecode/libs/deepagents/deepagents/backends/protocol.py b/deepagents_sourcecode/libs/deepagents/deepagents/backends/protocol.py index e305ca3..76da100 100644 --- a/deepagents_sourcecode/libs/deepagents/deepagents/backends/protocol.py +++ b/deepagents_sourcecode/libs/deepagents/deepagents/backends/protocol.py @@ -1,8 +1,8 @@ -"""Protocol definition for pluggable memory backends. +"""플러그인 가능한 메모리 백엔드를 위한 프로토콜 정의입니다. -This module defines the BackendProtocol that all backend implementations -must follow. Backends can store files in different locations (state, filesystem, -database, etc.) and provide a uniform interface for file operations. +이 모듈은 모든 백엔드 구현이 따라야 하는 BackendProtocol을 정의합니다. +백엔드는 다양한 위치(상태, 파일 시스템, 데이터베이스 등)에 파일을 저장할 수 있으며 +파일 작업을 위한 균일한 인터페이스를 제공합니다. """ import abc @@ -15,42 +15,41 @@ from langchain.tools import ToolRuntime from typing_extensions import TypedDict FileOperationError = Literal[ - "file_not_found", # Download: file doesn't exist - "permission_denied", # Both: access denied - "is_directory", # Download: tried to download directory as file - "invalid_path", # Both: path syntax malformed (parent dir missing, invalid chars) + "file_not_found", # Download: 파일이 존재하지 않음 + "permission_denied", # Both: 접근 거부됨 + "is_directory", # Download: 디렉토리를 파일로 다운로드하려고 시도 + "invalid_path", # Both: 경로 문법이 잘못되었습니다(상위 디렉토리 누락, 잘못된 문자) ] -"""Standardized error codes for file upload/download operations. +"""파일 업로드/다운로드 작업을 위한 표준화된 오류 코드입니다. -These represent common, recoverable errors that an LLM can understand and potentially fix: -- file_not_found: The requested file doesn't exist (download) -- parent_not_found: The parent directory doesn't exist (upload) -- permission_denied: Access denied for the operation -- is_directory: Attempted to download a directory as a file -- invalid_path: Path syntax is malformed or contains invalid characters +이는 LLM이 이해하고 잠재적으로 수정할 수 있는 일반적이고 복구 가능한 오류를 나타냅니다: +- file_not_found: 요청한 파일이 존재하지 않음(다운로드) +- parent_not_found: 상위 디렉토리가 존재하지 않음(업로드) +- permission_denied: 작업에 대한 접근 거부됨 +- is_directory: 디렉토리를 파일로 다운로드하려고 시도 +- invalid_path: 경로 문법이 잘못되었거나 잘못된 문자가 포함됨 """ @dataclass class FileDownloadResponse: - """Result of a single file download operation. + """단일 파일 다운로드 작업의 결과입니다. - The response is designed to allow partial success in batch operations. - The errors are standardized using FileOperationError literals - for certain recoverable conditions for use cases that involve - LLMs performing file operations. + 이 응답은 배치 작업에서 부분적 성공을 허용하도록 설계되었습니다. + 오류는 LLM이 파일 작업을 수행하는 사용 사례에서 복구 가능한 + 특정 조건에 대해 FileOperationError 리터럴을 사용하여 표준화됩니다. Attributes: - path: The file path that was requested. Included for easy correlation - when processing batch results, especially useful for error messages. - content: File contents as bytes on success, None on failure. - error: Standardized error code on failure, None on success. - Uses FileOperationError literal for structured, LLM-actionable error reporting. + path: 요청된 파일 경로입니다. 배치 결과 처리 시 상관관계에 유용하며, + 특히 오류 메시지에 유용합니다. + content: 성공 시 파일 내용(바이트), 실패 시 None. + error: 실패 시 표준화된 오류 코드, 성공 시 None. + 구조화되고 LLM이 조치 가능한 오류 보고를 위해 FileOperationError 리터럴을 사용합니다. Examples: - >>> # Success + >>> # 성공 >>> FileDownloadResponse(path="/app/config.json", content=b"{...}", error=None) - >>> # Failure + >>> # 실패 >>> FileDownloadResponse(path="/wrong/path.txt", content=None, error="file_not_found") """ @@ -61,23 +60,22 @@ class FileDownloadResponse: @dataclass class FileUploadResponse: - """Result of a single file upload operation. + """단일 파일 업로드 작업의 결과입니다. - The response is designed to allow partial success in batch operations. - The errors are standardized using FileOperationError literals - for certain recoverable conditions for use cases that involve - LLMs performing file operations. + 이 응답은 배치 작업에서 부분적 성공을 허용하도록 설계되었습니다. + 오류는 LLM이 파일 작업을 수행하는 사용 사례에서 복구 가능한 + 특정 조건에 대해 FileOperationError 리터럴을 사용하여 표준화됩니다. Attributes: - path: The file path that was requested. Included for easy correlation - when processing batch results and for clear error messages. - error: Standardized error code on failure, None on success. - Uses FileOperationError literal for structured, LLM-actionable error reporting. + path: 요청된 파일 경로입니다. 배치 결과 처리 시 상관관계에 유용하며, + 명확한 오류 메시지에 도움이 됩니다. + error: 실패 시 표준화된 오류 코드, 성공 시 None. + 구조화되고 LLM이 조치 가능한 오류 보고를 위해 FileOperationError 리터럴을 사용합니다. Examples: - >>> # Success + >>> # 성공 >>> FileUploadResponse(path="/app/data.txt", error=None) - >>> # Failure + >>> # 실패 >>> FileUploadResponse(path="/readonly/file.txt", error="permission_denied") """ @@ -86,10 +84,10 @@ class FileUploadResponse: class FileInfo(TypedDict): - """Structured file listing info. + """파일 목록 조회 시 사용하는 구조화된 항목 정보입니다. - Minimal contract used across backends. Only "path" is required. - Other fields are best-effort and may be absent depending on backend. + 백엔드 간 최소 계약(minimal contract)이며, `"path"`만 필수입니다. + 나머지 필드는 best-effort로 제공되며, 백엔드에 따라 누락될 수 있습니다. """ path: str @@ -99,7 +97,7 @@ class FileInfo(TypedDict): class GrepMatch(TypedDict): - """Structured grep match entry.""" + """grep 매칭 결과(구조화) 엔트리입니다.""" path: str line: int @@ -108,14 +106,15 @@ class GrepMatch(TypedDict): @dataclass class WriteResult: - """Result from backend write operations. + """백엔드 write 작업의 결과입니다. Attributes: - error: Error message on failure, None on success. - path: Absolute path of written file, None on failure. - files_update: State update dict for checkpoint backends, None for external storage. - Checkpoint backends populate this with {file_path: file_data} for LangGraph state. - External backends set None (already persisted to disk/S3/database/etc). + error: 실패 시 오류 메시지, 성공 시 `None`. + path: 성공 시 작성된 파일의 절대 경로, 실패 시 `None`. + files_update: checkpoint 기반 백엔드에서는 state 업데이트 딕셔너리, + 외부 스토리지 기반 백엔드에서는 `None`. + checkpoint 백엔드는 LangGraph state 업데이트를 위해 `{file_path: file_data}`를 채웁니다. + 외부 백엔드는 `None`(이미 디스크/S3/DB 등에 영구 반영됨)을 사용합니다. Examples: >>> # Checkpoint storage @@ -133,15 +132,16 @@ class WriteResult: @dataclass class EditResult: - """Result from backend edit operations. + """백엔드 edit 작업의 결과입니다. Attributes: - error: Error message on failure, None on success. - path: Absolute path of edited file, None on failure. - files_update: State update dict for checkpoint backends, None for external storage. - Checkpoint backends populate this with {file_path: file_data} for LangGraph state. - External backends set None (already persisted to disk/S3/database/etc). - occurrences: Number of replacements made, None on failure. + error: 실패 시 오류 메시지, 성공 시 `None`. + path: 성공 시 수정된 파일의 절대 경로, 실패 시 `None`. + files_update: checkpoint 기반 백엔드에서는 state 업데이트 딕셔너리, + 외부 스토리지 기반 백엔드에서는 `None`. + checkpoint 백엔드는 LangGraph state 업데이트를 위해 `{file_path: file_data}`를 채웁니다. + 외부 백엔드는 `None`(이미 디스크/S3/DB 등에 영구 반영됨)을 사용합니다. + occurrences: 치환 횟수. 실패 시 `None`. Examples: >>> # Checkpoint storage @@ -159,36 +159,38 @@ class EditResult: class BackendProtocol(abc.ABC): - """Protocol for pluggable memory backends (single, unified). + """플러그인 가능한 메모리/파일 백엔드용 단일(unified) 프로토콜입니다. - Backends can store files in different locations (state, filesystem, database, etc.) - and provide a uniform interface for file operations. + 백엔드는 상태(state), 로컬 파일 시스템, 데이터베이스 등 다양한 위치에 파일을 저장할 수 있으며, + 파일 작업에 대해 일관된(uniform) 인터페이스를 제공합니다. - All file data is represented as dicts with the following structure: + 모든 file data는 아래 구조의 딕셔너리로 표현합니다. + + ```python { - "content": list[str], # Lines of text content - "created_at": str, # ISO format timestamp - "modified_at": str, # ISO format timestamp + "content": list[str], # 텍스트 라인 목록 + "created_at": str, # ISO 형식 타임스탬프 + "modified_at": str, # ISO 형식 타임스탬프 } + ``` """ def ls_info(self, path: str) -> list["FileInfo"]: - """List all files in a directory with metadata. + """디렉토리 내 파일/폴더를 메타데이터와 함께 나열합니다. Args: - path: Absolute path to the directory to list. Must start with '/'. + path: 나열할 디렉토리의 절대 경로. 반드시 `/`로 시작해야 합니다. Returns: - List of FileInfo dicts containing file metadata: - - - `path` (required): Absolute file path - - `is_dir` (optional): True if directory - - `size` (optional): File size in bytes - - `modified_at` (optional): ISO 8601 timestamp + FileInfo 딕셔너리 리스트: + - `path` (필수): 절대 경로 + - `is_dir` (선택): 디렉토리면 `True` + - `size` (선택): 바이트 단위 크기 + - `modified_at` (선택): ISO 8601 타임스탬프 """ async def als_info(self, path: str) -> list["FileInfo"]: - """Async version of ls_info.""" + """`ls_info`의 async 버전입니다.""" return await asyncio.to_thread(self.ls_info, path) def read( @@ -197,25 +199,25 @@ class BackendProtocol(abc.ABC): offset: int = 0, limit: int = 2000, ) -> str: - """Read file content with line numbers. + """파일을 읽어 라인 번호가 포함된 문자열로 반환합니다. Args: - file_path: Absolute path to the file to read. Must start with '/'. - offset: Line number to start reading from (0-indexed). Default: 0. - limit: Maximum number of lines to read. Default: 2000. + file_path: 읽을 파일의 절대 경로. 반드시 `/`로 시작해야 합니다. + offset: 읽기 시작 라인(0-index). 기본값: 0. + limit: 최대 읽기 라인 수. 기본값: 2000. Returns: - String containing file content formatted with line numbers (cat -n format), - starting at line 1. Lines longer than 2000 characters are truncated. + 라인 번호(`cat -n`) 형식으로 포맷된 파일 내용 문자열(라인 번호는 1부터 시작). + 2000자를 초과하는 라인은 잘립니다. - Returns an error string if the file doesn't exist or can't be read. + 파일이 없거나 읽을 수 없으면 오류 문자열을 반환합니다. !!! note - - Use pagination (offset/limit) for large files to avoid context overflow - - First scan: `read(path, limit=100)` to see file structure - - Read more: `read(path, offset=100, limit=200)` for next section - - ALWAYS read a file before editing it - - If file exists but is empty, you'll receive a system reminder warning + - 큰 파일은 pagination(offset/limit)을 사용해 컨텍스트 오버플로우를 방지하세요. + - 첫 스캔: `read(path, limit=100)`으로 구조 파악 + - 추가 읽기: `read(path, offset=100, limit=200)`으로 다음 구간 + - 편집 전에는 반드시 파일을 먼저 읽어야 합니다. + - 파일이 비어 있으면 system reminder 경고가 반환될 수 있습니다. """ async def aread( @@ -224,7 +226,7 @@ class BackendProtocol(abc.ABC): offset: int = 0, limit: int = 2000, ) -> str: - """Async version of read.""" + """`read`의 async 버전입니다.""" return await asyncio.to_thread(self.read, file_path, offset, limit) def grep_raw( @@ -233,7 +235,7 @@ class BackendProtocol(abc.ABC): path: str | None = None, glob: str | None = None, ) -> list["GrepMatch"] | str: - """Search for a literal text pattern in files. + """파일에서 리터럴(비정규식) 텍스트 패턴을 검색합니다. Args: pattern: Literal string to search for (NOT regex). @@ -259,12 +261,12 @@ class BackendProtocol(abc.ABC): - "test[0-9].txt" - search test0.txt, test1.txt, etc. Returns: - On success: list[GrepMatch] with structured results containing: - - path: Absolute file path - - line: Line number (1-indexed) - - text: Full line content containing the match + 성공 시: 아래 필드를 가진 구조화 결과 `list[GrepMatch]` + - path: 절대 파일 경로 + - line: 라인 번호(1-index) + - text: 매칭된 라인의 전체 텍스트 - On error: str with error message (e.g., invalid path, permission denied) + 실패 시: 오류 메시지 문자열(예: invalid path, permission denied) """ async def agrep_raw( @@ -273,11 +275,11 @@ class BackendProtocol(abc.ABC): path: str | None = None, glob: str | None = None, ) -> list["GrepMatch"] | str: - """Async version of grep_raw.""" + """`grep_raw`의 async 버전입니다.""" return await asyncio.to_thread(self.grep_raw, pattern, path, glob) def glob_info(self, pattern: str, path: str = "/") -> list["FileInfo"]: - """Find files matching a glob pattern. + """Glob 패턴에 매칭되는 파일을 찾습니다. Args: pattern: Glob pattern with wildcards to match file paths. @@ -291,11 +293,11 @@ class BackendProtocol(abc.ABC): The pattern is applied relative to this path. Returns: - list of FileInfo + FileInfo 리스트 """ async def aglob_info(self, pattern: str, path: str = "/") -> list["FileInfo"]: - """Async version of glob_info.""" + """`glob_info`의 async 버전입니다.""" return await asyncio.to_thread(self.glob_info, pattern, path) def write( @@ -303,7 +305,7 @@ class BackendProtocol(abc.ABC): file_path: str, content: str, ) -> WriteResult: - """Write content to a new file in the filesystem, error if file exists. + """새 파일을 생성하고 내용을 씁니다(동일 경로 파일이 이미 있으면 오류). Args: file_path: Absolute path where the file should be created. @@ -319,7 +321,7 @@ class BackendProtocol(abc.ABC): file_path: str, content: str, ) -> WriteResult: - """Async version of write.""" + """`write`의 async 버전입니다.""" return await asyncio.to_thread(self.write, file_path, content) def edit( @@ -329,7 +331,7 @@ class BackendProtocol(abc.ABC): new_string: str, replace_all: bool = False, ) -> EditResult: - """Perform exact string replacements in an existing file. + """기존 파일에서 정확한 문자열 매칭 기반 치환을 수행합니다. Args: file_path: Absolute path to the file to edit. Must start with '/'. @@ -351,11 +353,11 @@ class BackendProtocol(abc.ABC): new_string: str, replace_all: bool = False, ) -> EditResult: - """Async version of edit.""" + """`edit`의 async 버전입니다.""" return await asyncio.to_thread(self.edit, file_path, old_string, new_string, replace_all) def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]: - """Upload multiple files to the sandbox. + """여러 파일을 샌드박스로 업로드합니다. This API is designed to allow developers to use it either directly or by exposing it to LLMs via custom tools. @@ -380,11 +382,11 @@ class BackendProtocol(abc.ABC): """ async def aupload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]: - """Async version of upload_files.""" + """`upload_files`의 async 버전입니다.""" return await asyncio.to_thread(self.upload_files, files) def download_files(self, paths: list[str]) -> list[FileDownloadResponse]: - """Download multiple files from the sandbox. + """여러 파일을 샌드박스에서 다운로드합니다. This API is designed to allow developers to use it either directly or by exposing it to LLMs via custom tools. @@ -399,41 +401,41 @@ class BackendProtocol(abc.ABC): """ async def adownload_files(self, paths: list[str]) -> list[FileDownloadResponse]: - """Async version of download_files.""" + """`download_files`의 async 버전입니다.""" return await asyncio.to_thread(self.download_files, paths) @dataclass class ExecuteResponse: - """Result of code execution. + """코드 실행 결과입니다. - Simplified schema optimized for LLM consumption. + LLM이 소비하기 좋도록 단순화한 스키마입니다. """ output: str - """Combined stdout and stderr output of the executed command.""" + """실행된 커맨드의 stdout+stderr 합쳐진 출력.""" exit_code: int | None = None - """The process exit code. 0 indicates success, non-zero indicates failure.""" + """프로세스 종료 코드. 0은 성공, 0이 아니면 실패.""" truncated: bool = False - """Whether the output was truncated due to backend limitations.""" + """백엔드 제한으로 출력이 잘렸는지 여부.""" class SandboxBackendProtocol(BackendProtocol): - """Protocol for sandboxed backends with isolated runtime. + """격리된 런타임을 제공하는 샌드박스 백엔드용 프로토콜입니다. - Sandboxed backends run in isolated environments (e.g., separate processes, - containers) and communicate via defined interfaces. + 샌드박스 백엔드는 별도 프로세스/컨테이너 같은 격리된 환경에서 실행되며, + 정해진 인터페이스를 통해 통신합니다. """ def execute( self, command: str, ) -> ExecuteResponse: - """Execute a command in the process. + """샌드박스 프로세스에서 커맨드를 실행합니다. - Simplified interface optimized for LLM consumption. + LLM 친화적으로 단순화된 인터페이스입니다. Args: command: Full shell command string to execute. @@ -446,12 +448,12 @@ class SandboxBackendProtocol(BackendProtocol): self, command: str, ) -> ExecuteResponse: - """Async version of execute.""" + """`execute`의 async 버전입니다.""" return await asyncio.to_thread(self.execute, command) @property def id(self) -> str: - """Unique identifier for the sandbox backend instance.""" + """샌드박스 백엔드 인스턴스의 고유 식별자.""" BackendFactory: TypeAlias = Callable[[ToolRuntime], BackendProtocol] diff --git a/deepagents_sourcecode/libs/deepagents/deepagents/backends/sandbox.py b/deepagents_sourcecode/libs/deepagents/deepagents/backends/sandbox.py index 29c4c51..ff423c8 100644 --- a/deepagents_sourcecode/libs/deepagents/deepagents/backends/sandbox.py +++ b/deepagents_sourcecode/libs/deepagents/deepagents/backends/sandbox.py @@ -1,8 +1,8 @@ -"""Base sandbox implementation with execute() as the only abstract method. +"""`execute()`만 구현하면 되는 샌드박스 기본 구현체입니다. -This module provides a base class that implements all SandboxBackendProtocol -methods using shell commands executed via execute(). Concrete implementations -only need to implement the execute() method. +이 모듈은 `execute()`로 실행되는 셸 커맨드를 이용해 `SandboxBackendProtocol`의 나머지 메서드를 +기본 구현으로 제공하는 베이스 클래스를 포함합니다. 구체 구현체(concrete implementation)는 +`execute()`만 구현하면 됩니다. """ from __future__ import annotations @@ -139,10 +139,10 @@ for i, line in enumerate(selected_lines): class BaseSandbox(SandboxBackendProtocol, ABC): - """Base sandbox implementation with execute() as abstract method. + """`execute()`를 추상 메서드로 두는 샌드박스 기본 구현체입니다. - This class provides default implementations for all protocol methods - using shell commands. Subclasses only need to implement execute(). + 셸 커맨드 기반으로 프로토콜 메서드들의 기본 구현을 제공하며, + 서브클래스는 `execute()`만 구현하면 됩니다. """ @abstractmethod @@ -150,7 +150,7 @@ class BaseSandbox(SandboxBackendProtocol, ABC): self, command: str, ) -> ExecuteResponse: - """Execute a command in the sandbox and return ExecuteResponse. + """샌드박스에서 커맨드를 실행하고 `ExecuteResponse`를 반환합니다. Args: command: Full shell command string to execute. @@ -161,7 +161,7 @@ class BaseSandbox(SandboxBackendProtocol, ABC): ... def ls_info(self, path: str) -> list[FileInfo]: - """Structured listing with file metadata using os.scandir.""" + """`os.scandir`를 사용해 파일 메타데이터를 포함한 구조화 목록을 반환합니다.""" cmd = f"""python3 -c " import os import json @@ -202,8 +202,8 @@ except PermissionError: offset: int = 0, limit: int = 2000, ) -> str: - """Read file content with line numbers using a single shell command.""" - # Use template for reading file with offset and limit + """단일 셸 커맨드로 파일을 읽고 라인 번호가 포함된 문자열로 반환합니다.""" + # offset/limit을 적용한 읽기 템플릿을 사용 cmd = _READ_COMMAND_TEMPLATE.format(file_path=file_path, offset=offset, limit=limit) result = self.execute(cmd) @@ -220,20 +220,23 @@ except PermissionError: file_path: str, content: str, ) -> WriteResult: - """Create a new file. Returns WriteResult; error populated on failure.""" - # Encode content as base64 to avoid any escaping issues + """새 파일을 생성하고 내용을 씁니다. + + 실패 시 `WriteResult.error`가 채워진 형태로 반환합니다. + """ + # escaping 이슈를 피하기 위해 content를 base64로 인코딩합니다. content_b64 = base64.b64encode(content.encode("utf-8")).decode("ascii") - # Single atomic check + write command + # 단일 커맨드에서 존재 여부 확인 + write를 원자적으로 수행 cmd = _WRITE_COMMAND_TEMPLATE.format(file_path=file_path, content_b64=content_b64) result = self.execute(cmd) - # Check for errors (exit code or error message in output) + # 오류 확인(비정상 exit code 또는 output 내 Error 메시지) if result.exit_code != 0 or "Error:" in result.output: error_msg = result.output.strip() or f"Failed to write file '{file_path}'" return WriteResult(error=error_msg) - # External storage - no files_update needed + # 외부 스토리지이므로 files_update는 필요 없음 return WriteResult(path=file_path, files_update=None) def edit( @@ -243,12 +246,12 @@ except PermissionError: new_string: str, replace_all: bool = False, ) -> EditResult: - """Edit a file by replacing string occurrences. Returns EditResult.""" - # Encode strings as base64 to avoid any escaping issues + """파일 내 문자열을 치환하여 편집합니다.""" + # escaping 이슈를 피하기 위해 문자열을 base64로 인코딩합니다. old_b64 = base64.b64encode(old_string.encode("utf-8")).decode("ascii") new_b64 = base64.b64encode(new_string.encode("utf-8")).decode("ascii") - # Use template for string replacement + # 문자열 치환 템플릿을 사용 cmd = _EDIT_COMMAND_TEMPLATE.format(file_path=file_path, old_b64=old_b64, new_b64=new_b64, replace_all=replace_all) result = self.execute(cmd) @@ -263,7 +266,7 @@ except PermissionError: return EditResult(error=f"Error: File '{file_path}' not found") count = int(output) - # External storage - no files_update needed + # 외부 스토리지이므로 files_update는 필요 없음 return EditResult(path=file_path, files_update=None, occurrences=count) def grep_raw( @@ -272,18 +275,18 @@ except PermissionError: path: str | None = None, glob: str | None = None, ) -> list[GrepMatch] | str: - """Structured search results or error string for invalid input.""" + """구조화된 검색 결과 또는(입력이 잘못된 경우) 오류 문자열을 반환합니다.""" search_path = shlex.quote(path or ".") - # Build grep command to get structured output + # 구조화된 출력 파싱을 위한 grep 커맨드 구성 grep_opts = "-rHnF" # recursive, with filename, with line number, fixed-strings (literal) - # Add glob pattern if specified + # glob 패턴이 있으면 include 조건으로 추가 glob_pattern = "" if glob: glob_pattern = f"--include='{glob}'" - # Escape pattern for shell + # 셸에서 안전하게 쓰도록 pattern을 escape pattern_escaped = shlex.quote(pattern) cmd = f"grep {grep_opts} {glob_pattern} -e {pattern_escaped} {search_path} 2>/dev/null || true" @@ -293,10 +296,10 @@ except PermissionError: if not output: return [] - # Parse grep output into GrepMatch objects + # grep 출력 문자열을 GrepMatch 객체로 파싱 matches: list[GrepMatch] = [] for line in output.split("\n"): - # Format is: path:line_number:text + # 포맷: path:line_number:text parts = line.split(":", 2) if len(parts) >= 3: matches.append( @@ -310,8 +313,8 @@ except PermissionError: return matches def glob_info(self, pattern: str, path: str = "/") -> list[FileInfo]: - """Structured glob matching returning FileInfo dicts.""" - # Encode pattern and path as base64 to avoid escaping issues + """Glob 매칭 결과를 구조화된 FileInfo 딕셔너리로 반환합니다.""" + # escaping 이슈를 피하기 위해 pattern/path를 base64로 인코딩합니다. pattern_b64 = base64.b64encode(pattern.encode("utf-8")).decode("ascii") path_b64 = base64.b64encode(path.encode("utf-8")).decode("ascii") @@ -322,7 +325,7 @@ except PermissionError: if not output: return [] - # Parse JSON output into FileInfo dicts + # JSON 출력을 FileInfo 딕셔너리로 파싱 file_infos: list[FileInfo] = [] for line in output.split("\n"): try: @@ -341,11 +344,11 @@ except PermissionError: @property @abstractmethod def id(self) -> str: - """Unique identifier for the sandbox backend.""" + """샌드박스 백엔드 인스턴스의 고유 식별자.""" @abstractmethod def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]: - """Upload multiple files to the sandbox. + """여러 파일을 샌드박스로 업로드합니다. Implementations must support partial success - catch exceptions per-file and return errors in FileUploadResponse objects rather than raising. @@ -353,7 +356,7 @@ except PermissionError: @abstractmethod def download_files(self, paths: list[str]) -> list[FileDownloadResponse]: - """Download multiple files from the sandbox. + """여러 파일을 샌드박스에서 다운로드합니다. Implementations must support partial success - catch exceptions per-file and return errors in FileDownloadResponse objects rather than raising. diff --git a/deepagents_sourcecode/libs/deepagents/deepagents/backends/state.py b/deepagents_sourcecode/libs/deepagents/deepagents/backends/state.py index 454a696..c1c08a7 100644 --- a/deepagents_sourcecode/libs/deepagents/deepagents/backends/state.py +++ b/deepagents_sourcecode/libs/deepagents/deepagents/backends/state.py @@ -1,4 +1,4 @@ -"""StateBackend: Store files in LangGraph agent state (ephemeral).""" +"""StateBackend: LangGraph 에이전트 상태에 파일을 저장합니다(일시적).""" from typing import TYPE_CHECKING @@ -26,54 +26,54 @@ if TYPE_CHECKING: class StateBackend(BackendProtocol): - """Backend that stores files in agent state (ephemeral). + """에이전트 상태(state)에 파일을 저장하는 백엔드입니다(일시적). - Uses LangGraph's state management and checkpointing. Files persist within - a conversation thread but not across threads. State is automatically - checkpointed after each agent step. + LangGraph의 state/checkpoint 메커니즘을 사용합니다. 파일은 동일한 스레드(대화) + 내에서는 유지되지만, 스레드를 넘어 영구 저장되지는 않습니다. 또한 state는 + 각 에이전트 step 이후 자동으로 checkpoint 됩니다. - Special handling: Since LangGraph state must be updated via Command objects - (not direct mutation), operations return Command objects instead of None. - This is indicated by the uses_state=True flag. + 주의: LangGraph state는 직접 mutation이 아니라 `Command` 객체를 통해 업데이트해야 합니다. + 따라서 일부 작업은 `None` 대신 `Command`에 적용될 업데이트 정보를 담아 반환합니다. + (코드에서는 `uses_state=True` 플래그로 표현) """ def __init__(self, runtime: "ToolRuntime"): - """Initialize StateBackend with runtime.""" + """`ToolRuntime`으로 StateBackend를 초기화합니다.""" self.runtime = runtime def ls_info(self, path: str) -> list[FileInfo]: - """List files and directories in the specified directory (non-recursive). + """지정한 디렉토리 바로 아래의 파일/폴더를 나열합니다(비재귀). Args: - path: Absolute path to directory. + path: 디렉토리 절대 경로. Returns: - List of FileInfo-like dicts for files and directories directly in the directory. - Directories have a trailing / in their path and is_dir=True. + 해당 디렉토리의 직계 자식 항목에 대한 FileInfo 딕셔너리 리스트. + 디렉토리는 경로가 `/`로 끝나며 `is_dir=True`입니다. """ files = self.runtime.state.get("files", {}) infos: list[FileInfo] = [] subdirs: set[str] = set() - # Normalize path to have trailing slash for proper prefix matching + # prefix 매칭을 위해 trailing slash를 갖는 형태로 정규화 normalized_path = path if path.endswith("/") else path + "/" for k, fd in files.items(): - # Check if file is in the specified directory or a subdirectory + # 지정 디렉토리(또는 하위 디렉토리)에 속하는지 확인 if not k.startswith(normalized_path): continue - # Get the relative path after the directory + # 디렉토리 이후의 상대 경로 relative = k[len(normalized_path) :] - # If relative path contains '/', it's in a subdirectory + # 상대 경로에 `/`가 있으면 하위 디렉토리에 있는 파일입니다. if "/" in relative: - # Extract the immediate subdirectory name + # 즉시 하위 디렉토리 이름만 추출 subdir_name = relative.split("/")[0] subdirs.add(normalized_path + subdir_name + "/") continue - # This is a file directly in the current directory + # 현재 디렉토리 바로 아래에 있는 파일 size = len("\n".join(fd.get("content", []))) infos.append( { @@ -84,7 +84,7 @@ class StateBackend(BackendProtocol): } ) - # Add directories to the results + # 디렉토리 항목을 결과에 추가 for subdir in sorted(subdirs): infos.append( { @@ -104,15 +104,15 @@ class StateBackend(BackendProtocol): offset: int = 0, limit: int = 2000, ) -> str: - """Read file content with line numbers. + """파일을 읽어 라인 번호가 포함된 문자열로 반환합니다. Args: - file_path: Absolute file path. - offset: Line offset to start reading from (0-indexed). - limit: Maximum number of lines to read. + file_path: 절대 파일 경로. + offset: 읽기 시작 라인 오프셋(0-index). + limit: 최대 읽기 라인 수. Returns: - Formatted file content with line numbers, or error message. + 라인 번호가 포함된 포맷 문자열 또는 오류 메시지. """ files = self.runtime.state.get("files", {}) file_data = files.get(file_path) @@ -127,8 +127,9 @@ class StateBackend(BackendProtocol): file_path: str, content: str, ) -> WriteResult: - """Create a new file with content. - Returns WriteResult with files_update to update LangGraph state. + """새 파일을 생성합니다. + + LangGraph state 업데이트를 위해 `files_update`가 포함된 `WriteResult`를 반환합니다. """ files = self.runtime.state.get("files", {}) @@ -145,8 +146,9 @@ class StateBackend(BackendProtocol): new_string: str, replace_all: bool = False, ) -> EditResult: - """Edit a file by replacing string occurrences. - Returns EditResult with files_update and occurrences. + """파일 내 문자열을 치환하여 편집합니다. + + `files_update` 및 치환 횟수(`occurrences`)가 포함된 `EditResult`를 반환합니다. """ files = self.runtime.state.get("files", {}) file_data = files.get(file_path) @@ -174,7 +176,7 @@ class StateBackend(BackendProtocol): return grep_matches_from_files(files, pattern, path, glob) def glob_info(self, pattern: str, path: str = "/") -> list[FileInfo]: - """Get FileInfo for files matching glob pattern.""" + """Glob 패턴에 매칭되는 파일에 대한 FileInfo를 반환합니다.""" files = self.runtime.state.get("files", {}) result = _glob_search_files(files, pattern, path) if result == "No files found": @@ -195,28 +197,14 @@ class StateBackend(BackendProtocol): return infos def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]: - """Upload multiple files to state. - - Args: - files: List of (path, content) tuples to upload - - Returns: - List of FileUploadResponse objects, one per input file - """ + """여러 파일을 state로 업로드합니다.""" raise NotImplementedError( "StateBackend does not support upload_files yet. You can upload files " "directly by passing them in invoke if you're storing files in the memory." ) def download_files(self, paths: list[str]) -> list[FileDownloadResponse]: - """Download multiple files from state. - - Args: - paths: List of file paths to download - - Returns: - List of FileDownloadResponse objects, one per input path - """ + """여러 파일을 state에서 다운로드합니다.""" state_files = self.runtime.state.get("files", {}) responses: list[FileDownloadResponse] = [] @@ -227,7 +215,7 @@ class StateBackend(BackendProtocol): responses.append(FileDownloadResponse(path=path, content=None, error="file_not_found")) continue - # Convert file data to bytes + # state의 FileData를 bytes로 변환 content_str = file_data_to_string(file_data) content_bytes = content_str.encode("utf-8") diff --git a/deepagents_sourcecode/libs/deepagents/deepagents/backends/store.py b/deepagents_sourcecode/libs/deepagents/deepagents/backends/store.py index dad08b8..edde202 100644 --- a/deepagents_sourcecode/libs/deepagents/deepagents/backends/store.py +++ b/deepagents_sourcecode/libs/deepagents/deepagents/backends/store.py @@ -1,4 +1,4 @@ -"""StoreBackend: Adapter for LangGraph's BaseStore (persistent, cross-thread).""" +"""StoreBackend: LangGraph `BaseStore` 어댑터(영구 저장, 스레드 간 공유).""" from typing import Any @@ -26,31 +26,20 @@ from deepagents.backends.utils import ( class StoreBackend(BackendProtocol): - """Backend that stores files in LangGraph's BaseStore (persistent). + """LangGraph `BaseStore`에 파일을 저장하는 백엔드입니다(영구 저장). - Uses LangGraph's Store for persistent, cross-conversation storage. - Files are organized via namespaces and persist across all threads. + LangGraph Store를 이용해 대화 스레드를 넘어서는 영구 저장을 제공합니다. + 파일은 namespace로 조직되며 모든 스레드에서 지속됩니다. - The namespace can include an optional assistant_id for multi-agent isolation. + 멀티 에이전트 격리를 위해 namespace에 `assistant_id`를 포함할 수도 있습니다. """ def __init__(self, runtime: "ToolRuntime"): - """Initialize StoreBackend with runtime. - - Args: - runtime: The ToolRuntime instance providing store access and configuration. - """ + """`ToolRuntime`으로 StoreBackend를 초기화합니다.""" self.runtime = runtime def _get_store(self) -> BaseStore: - """Get the store instance. - - Returns: - BaseStore instance from the runtime. - - Raises: - ValueError: If no store is available in the runtime. - """ + """runtime으로부터 store 인스턴스를 가져옵니다.""" store = self.runtime.store if store is None: msg = "Store is required but not available in runtime" @@ -58,7 +47,7 @@ class StoreBackend(BackendProtocol): return store def _get_namespace(self) -> tuple[str, ...]: - """Get the namespace for store operations. + """Store 작업에 사용할 namespace를 구합니다. Preference order: 1) Use `self.runtime.config` if present (tests pass this explicitly). @@ -95,17 +84,7 @@ class StoreBackend(BackendProtocol): return (namespace,) def _convert_store_item_to_file_data(self, store_item: Item) -> dict[str, Any]: - """Convert a store Item to FileData format. - - Args: - store_item: The store Item containing file data. - - Returns: - FileData dict with content, created_at, and modified_at fields. - - Raises: - ValueError: If required fields are missing or have incorrect types. - """ + """Store `Item`을 FileData 형식 딕셔너리로 변환합니다.""" if "content" not in store_item.value or not isinstance(store_item.value["content"], list): msg = f"Store item does not contain valid content field. Got: {store_item.value.keys()}" raise ValueError(msg) @@ -122,14 +101,7 @@ class StoreBackend(BackendProtocol): } def _convert_file_data_to_store_value(self, file_data: dict[str, Any]) -> dict[str, Any]: - """Convert FileData to a dict suitable for store.put(). - - Args: - file_data: The FileData to convert. - - Returns: - Dictionary with content, created_at, and modified_at fields. - """ + """FileData를 `store.put()`에 넣기 좋은 형태로 변환합니다.""" return { "content": file_data["content"], "created_at": file_data["created_at"], @@ -145,7 +117,7 @@ class StoreBackend(BackendProtocol): filter: dict[str, Any] | None = None, page_size: int = 100, ) -> list[Item]: - """Search store with automatic pagination to retrieve all results. + """Store 검색을 pagination으로 반복하여 모든 결과를 수집합니다. Args: store: The store to search. @@ -184,7 +156,7 @@ class StoreBackend(BackendProtocol): return all_items def ls_info(self, path: str) -> list[FileInfo]: - """List files and directories in the specified directory (non-recursive). + """지정한 디렉토리 바로 아래의 파일/폴더를 나열합니다(비재귀). Args: path: Absolute path to directory. @@ -196,31 +168,31 @@ class StoreBackend(BackendProtocol): store = self._get_store() namespace = self._get_namespace() - # Retrieve all items and filter by path prefix locally to avoid - # coupling to store-specific filter semantics + # store별 filter semantics에 결합되지 않도록 전체 아이템을 가져온 뒤, + # path prefix 필터링은 로컬에서 수행합니다. items = self._search_store_paginated(store, namespace) infos: list[FileInfo] = [] subdirs: set[str] = set() - # Normalize path to have trailing slash for proper prefix matching + # prefix 매칭을 위해 trailing slash를 갖는 형태로 정규화 normalized_path = path if path.endswith("/") else path + "/" for item in items: - # Check if file is in the specified directory or a subdirectory + # 지정 디렉토리(또는 하위 디렉토리)에 속하는지 확인 if not str(item.key).startswith(normalized_path): continue - # Get the relative path after the directory + # 디렉토리 이후의 상대 경로 relative = str(item.key)[len(normalized_path) :] - # If relative path contains '/', it's in a subdirectory + # 상대 경로에 `/`가 있으면 하위 디렉토리입니다. if "/" in relative: - # Extract the immediate subdirectory name + # 즉시 하위 디렉토리 이름만 추출 subdir_name = relative.split("/")[0] subdirs.add(normalized_path + subdir_name + "/") continue - # This is a file directly in the current directory + # 현재 디렉토리 바로 아래의 파일 try: fd = self._convert_store_item_to_file_data(item) except ValueError: @@ -235,7 +207,7 @@ class StoreBackend(BackendProtocol): } ) - # Add directories to the results + # 디렉토리 항목을 결과에 추가 for subdir in sorted(subdirs): infos.append( { @@ -255,7 +227,7 @@ class StoreBackend(BackendProtocol): offset: int = 0, limit: int = 2000, ) -> str: - """Read file content with line numbers. + """파일을 읽어 라인 번호가 포함된 문자열로 반환합니다. Args: file_path: Absolute file path. @@ -284,18 +256,19 @@ class StoreBackend(BackendProtocol): file_path: str, content: str, ) -> WriteResult: - """Create a new file with content. - Returns WriteResult. External storage sets files_update=None. + """새 파일을 생성하고 내용을 씁니다. + + `WriteResult`를 반환합니다. 외부 스토리지 백엔드는 `files_update=None`을 사용합니다. """ store = self._get_store() namespace = self._get_namespace() - # Check if file exists + # 파일 존재 여부 확인 existing = store.get(namespace, file_path) if existing is not None: return WriteResult(error=f"Cannot write to {file_path} because it already exists. Read and then make an edit, or write to a new path.") - # Create new file + # 새 파일 생성 file_data = create_file_data(content) store_value = self._convert_file_data_to_store_value(file_data) store.put(namespace, file_path, store_value) @@ -308,13 +281,14 @@ class StoreBackend(BackendProtocol): new_string: str, replace_all: bool = False, ) -> EditResult: - """Edit a file by replacing string occurrences. - Returns EditResult. External storage sets files_update=None. + """파일 내 문자열을 치환하여 편집합니다. + + `EditResult`를 반환합니다. 외부 스토리지 백엔드는 `files_update=None`을 사용합니다. """ store = self._get_store() namespace = self._get_namespace() - # Get existing file + # 기존 파일 조회 item = store.get(namespace, file_path) if item is None: return EditResult(error=f"Error: File '{file_path}' not found") @@ -333,12 +307,12 @@ class StoreBackend(BackendProtocol): new_content, occurrences = result new_file_data = update_file_data(file_data, new_content) - # Update file in store + # store에 업데이트 반영 store_value = self._convert_file_data_to_store_value(new_file_data) store.put(namespace, file_path, store_value) return EditResult(path=file_path, files_update=None, occurrences=int(occurrences)) - # Removed legacy grep() convenience to keep lean surface + # API 표면을 간결하게 유지하기 위해 legacy grep() convenience는 제거됨 def grep_raw( self, @@ -386,7 +360,7 @@ class StoreBackend(BackendProtocol): return infos def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]: - """Upload multiple files to the store. + """여러 파일을 store에 업로드합니다. Args: files: List of (path, content) tuples where content is bytes. @@ -401,18 +375,18 @@ class StoreBackend(BackendProtocol): for path, content in files: content_str = content.decode("utf-8") - # Create file data + # 파일 데이터 생성 file_data = create_file_data(content_str) store_value = self._convert_file_data_to_store_value(file_data) - # Store the file + # 파일 저장 store.put(namespace, path, store_value) responses.append(FileUploadResponse(path=path, error=None)) return responses def download_files(self, paths: list[str]) -> list[FileDownloadResponse]: - """Download multiple files from the store. + """여러 파일을 store에서 다운로드합니다. Args: paths: List of file paths to download. @@ -433,7 +407,7 @@ class StoreBackend(BackendProtocol): continue file_data = self._convert_store_item_to_file_data(item) - # Convert file data to bytes + # FileData를 bytes로 변환 content_str = file_data_to_string(file_data) content_bytes = content_str.encode("utf-8") diff --git a/deepagents_sourcecode/libs/deepagents/deepagents/backends/utils.py b/deepagents_sourcecode/libs/deepagents/deepagents/backends/utils.py index 437c6f6..ba094ee 100644 --- a/deepagents_sourcecode/libs/deepagents/deepagents/backends/utils.py +++ b/deepagents_sourcecode/libs/deepagents/deepagents/backends/utils.py @@ -1,8 +1,9 @@ -"""Shared utility functions for memory backend implementations. +"""메모리 백엔드 구현에서 공용으로 사용하는 유틸리티 함수 모음입니다. -This module contains both user-facing string formatters and structured -helpers used by backends and the composite router. Structured helpers -enable composition without fragile string parsing. +이 모듈에는 (1) 사용자/에이전트에게 보여줄 문자열 포매터와 (2) 백엔드 및 +Composite 라우터에서 사용하는 구조화된 헬퍼가 함께 들어 있습니다. +구조화 헬퍼를 사용하면 문자열 파싱에 의존하지 않고도 조합(composition)이 +가능해집니다. """ import re @@ -21,15 +22,16 @@ LINE_NUMBER_WIDTH = 6 TOOL_RESULT_TOKEN_LIMIT = 20000 # Same threshold as eviction TRUNCATION_GUIDANCE = "... [results truncated, try being more specific with your parameters]" -# Re-export protocol types for backwards compatibility +# 하위 호환성을 위해 protocol 타입을 재노출(re-export)합니다. FileInfo = _FileInfo GrepMatch = _GrepMatch def sanitize_tool_call_id(tool_call_id: str) -> str: - r"""Sanitize tool_call_id to prevent path traversal and separator issues. + r"""`tool_call_id`를 안전하게 정규화합니다. - Replaces dangerous characters (., /, \) with underscores. + 경로 탐색(path traversal)이나 구분자 관련 문제를 피하기 위해 위험한 문자(`.`, `/`, `\`)를 + 밑줄(`_`)로 치환합니다. """ sanitized = tool_call_id.replace(".", "_").replace("/", "_").replace("\\", "_") return sanitized @@ -39,16 +41,16 @@ def format_content_with_line_numbers( content: str | list[str], start_line: int = 1, ) -> str: - """Format file content with line numbers (cat -n style). + """파일 내용을 라인 번호와 함께 포맷팅합니다(`cat -n` 스타일). - Chunks lines longer than MAX_LINE_LENGTH with continuation markers (e.g., 5.1, 5.2). + `MAX_LINE_LENGTH`를 초과하는 라인은 연속 마커(예: `5.1`, `5.2`)를 붙여 여러 줄로 분할합니다. Args: - content: File content as string or list of lines - start_line: Starting line number (default: 1) + content: 문자열 또는 라인 리스트 형태의 파일 내용 + start_line: 시작 라인 번호(기본값: 1) Returns: - Formatted content with line numbers and continuation markers + 라인 번호와 연속 마커가 포함된 포맷 문자열 """ if isinstance(content, str): lines = content.split("\n") @@ -64,17 +66,17 @@ def format_content_with_line_numbers( if len(line) <= MAX_LINE_LENGTH: result_lines.append(f"{line_num:{LINE_NUMBER_WIDTH}d}\t{line}") else: - # Split long line into chunks with continuation markers + # 긴 라인을 여러 조각으로 분할하고 연속 마커를 부여합니다. num_chunks = (len(line) + MAX_LINE_LENGTH - 1) // MAX_LINE_LENGTH for chunk_idx in range(num_chunks): start = chunk_idx * MAX_LINE_LENGTH end = min(start + MAX_LINE_LENGTH, len(line)) chunk = line[start:end] if chunk_idx == 0: - # First chunk: use normal line number + # 첫 번째 조각: 일반 라인 번호 사용 result_lines.append(f"{line_num:{LINE_NUMBER_WIDTH}d}\t{chunk}") else: - # Continuation chunks: use decimal notation (e.g., 5.1, 5.2) + # 후속 조각: 소수 표기(예: 5.1, 5.2) continuation_marker = f"{line_num}.{chunk_idx}" result_lines.append(f"{continuation_marker:>{LINE_NUMBER_WIDTH}}\t{chunk}") @@ -82,13 +84,13 @@ def format_content_with_line_numbers( def check_empty_content(content: str) -> str | None: - """Check if content is empty and return warning message. + """콘텐츠가 비어 있는지 확인하고, 비어 있으면 경고 메시지를 반환합니다. Args: - content: Content to check + content: 확인할 콘텐츠 Returns: - Warning message if empty, None otherwise + 비어 있으면 경고 메시지, 아니면 `None` """ if not content or content.strip() == "": return EMPTY_CONTENT_WARNING @@ -96,26 +98,26 @@ def check_empty_content(content: str) -> str | None: def file_data_to_string(file_data: dict[str, Any]) -> str: - """Convert FileData to plain string content. + """FileData 딕셔너리를 일반 문자열 콘텐츠로 변환합니다. Args: - file_data: FileData dict with 'content' key + file_data: `'content'` 키를 포함한 FileData 딕셔너리 Returns: - Content as string with lines joined by newlines + 줄바꿈으로 합쳐진 문자열 콘텐츠 """ return "\n".join(file_data["content"]) def create_file_data(content: str, created_at: str | None = None) -> dict[str, Any]: - """Create a FileData object with timestamps. + """타임스탬프를 포함한 FileData 딕셔너리를 생성합니다. Args: - content: File content as string - created_at: Optional creation timestamp (ISO format) + content: 파일 내용(문자열) + created_at: 생성 시각(ISO 형식) 오버라이드(선택) Returns: - FileData dict with content and timestamps + content/created_at/modified_at를 포함한 FileData 딕셔너리 """ lines = content.split("\n") if isinstance(content, str) else content now = datetime.now(UTC).isoformat() @@ -128,14 +130,14 @@ def create_file_data(content: str, created_at: str | None = None) -> dict[str, A def update_file_data(file_data: dict[str, Any], content: str) -> dict[str, Any]: - """Update FileData with new content, preserving creation timestamp. + """기존 FileData의 생성 시각을 유지하면서 내용을 업데이트합니다. Args: - file_data: Existing FileData dict - content: New content as string + file_data: 기존 FileData 딕셔너리 + content: 새 콘텐츠(문자열) Returns: - Updated FileData dict + 업데이트된 FileData 딕셔너리 """ lines = content.split("\n") if isinstance(content, str) else content now = datetime.now(UTC).isoformat() @@ -152,15 +154,15 @@ def format_read_response( offset: int, limit: int, ) -> str: - """Format file data for read response with line numbers. + """`read` 응답을 라인 번호와 함께 포맷팅합니다. Args: - file_data: FileData dict - offset: Line offset (0-indexed) - limit: Maximum number of lines + file_data: FileData 딕셔너리 + offset: 라인 오프셋(0-index) + limit: 최대 라인 수 Returns: - Formatted content or error message + 포맷된 콘텐츠 또는 오류 메시지 """ content = file_data_to_string(file_data) empty_msg = check_empty_content(content) @@ -184,16 +186,16 @@ def perform_string_replacement( new_string: str, replace_all: bool, ) -> tuple[str, int] | str: - """Perform string replacement with occurrence validation. + """문자열 치환을 수행하고, 치환 대상 문자열의 출현 횟수를 검증합니다. Args: - content: Original content - old_string: String to replace - new_string: Replacement string - replace_all: Whether to replace all occurrences + content: 원본 콘텐츠 + old_string: 치환할 문자열 + new_string: 대체 문자열 + replace_all: 모든 출현을 치환할지 여부 Returns: - Tuple of (new_content, occurrences) on success, or error message string + 성공 시 `(new_content, occurrences)` 튜플, 실패 시 오류 메시지 문자열 """ occurrences = content.count(old_string) @@ -208,7 +210,7 @@ def perform_string_replacement( def truncate_if_too_long(result: list[str] | str) -> list[str] | str: - """Truncate list or string result if it exceeds token limit (rough estimate: 4 chars/token).""" + """토큰 제한을 초과하는 결과를 잘라냅니다(대략 4 chars/token 기준).""" if isinstance(result, list): total_chars = sum(len(item) for item in result) if total_chars > TOOL_RESULT_TOKEN_LIMIT * 4: @@ -221,16 +223,16 @@ def truncate_if_too_long(result: list[str] | str) -> list[str] | str: def _validate_path(path: str | None) -> str: - """Validate and normalize a path. + """경로를 검증하고 정규화합니다. Args: - path: Path to validate + path: 검증할 경로 Returns: - Normalized path starting with / + `/`로 시작하고 `/`로 끝나는 정규화된 경로 Raises: - ValueError: If path is invalid + ValueError: 경로가 유효하지 않은 경우 """ path = path or "/" if not path or path.strip() == "": @@ -249,7 +251,7 @@ def _glob_search_files( pattern: str, path: str = "/", ) -> str: - """Search files dict for paths matching glob pattern. + """in-memory 파일 맵에서 glob 패턴에 매칭되는 경로를 찾습니다. Args: files: Dictionary of file paths to FileData. @@ -274,10 +276,9 @@ def _glob_search_files( filtered = {fp: fd for fp, fd in files.items() if fp.startswith(normalized_path)} - # Respect standard glob semantics: - # - Patterns without path separators (e.g., "*.py") match only in the current - # directory (non-recursive) relative to `path`. - # - Use "**" explicitly for recursive matching. + # 표준 glob semantics를 따릅니다. + # - path separator가 없는 패턴(예: "*.py")은 `path` 기준 현재 디렉토리(비재귀)만 매칭합니다. + # - 재귀 매칭이 필요하면 "**"를 명시적으로 사용해야 합니다. effective_pattern = pattern matches = [] @@ -301,7 +302,7 @@ def _format_grep_results( results: dict[str, list[tuple[int, str]]], output_mode: Literal["files_with_matches", "content", "count"], ) -> str: - """Format grep search results based on output mode. + """Output mode에 따라 grep 검색 결과를 포맷팅합니다. Args: results: Dictionary mapping file paths to list of (line_num, line_content) tuples @@ -333,7 +334,7 @@ def _grep_search_files( glob: str | None = None, output_mode: Literal["files_with_matches", "content", "count"] = "files_with_matches", ) -> str: - """Search file contents for regex pattern. + """파일 내용에서 정규식 패턴을 검색합니다. Args: files: Dictionary of file paths to FileData. @@ -380,7 +381,7 @@ def _grep_search_files( return _format_grep_results(results, output_mode) -# -------- Structured helpers for composition -------- +# -------- 조합(composition)을 위한 구조화 헬퍼 -------- def grep_matches_from_files( @@ -389,11 +390,11 @@ def grep_matches_from_files( path: str | None = None, glob: str | None = None, ) -> list[GrepMatch] | str: - """Return structured grep matches from an in-memory files mapping. + """in-memory 파일 맵에서 구조화된 grep 매칭을 반환합니다. - Returns a list of GrepMatch on success, or a string for invalid inputs - (e.g., invalid regex). We deliberately do not raise here to keep backends - non-throwing in tool contexts and preserve user-facing error messages. + 성공 시 `list[GrepMatch]`를, 입력이 유효하지 않은 경우(예: 잘못된 정규식)는 오류 문자열을 반환합니다. + 백엔드가 도구(tool) 컨텍스트에서 예외를 던지지 않도록 하고, 사용자/에이전트에게 보여줄 오류 메시지를 + 유지하기 위해 의도적으로 raise 하지 않습니다. """ try: regex = re.compile(pattern) @@ -419,7 +420,7 @@ def grep_matches_from_files( def build_grep_results_dict(matches: list[GrepMatch]) -> dict[str, list[tuple[int, str]]]: - """Group structured matches into the legacy dict form used by formatters.""" + """구조화 매칭을 기존(formatter) 호환 dict 형태로 그룹화합니다.""" grouped: dict[str, list[tuple[int, str]]] = {} for m in matches: grouped.setdefault(m["path"], []).append((m["line"], m["text"])) @@ -430,7 +431,7 @@ def format_grep_matches( matches: list[GrepMatch], output_mode: Literal["files_with_matches", "content", "count"], ) -> str: - """Format structured grep matches using existing formatting logic.""" + """기존 포맷팅 로직을 이용해 구조화 grep 매칭을 문자열로 포맷팅합니다.""" if not matches: return "No matches found" return _format_grep_results(build_grep_results_dict(matches), output_mode) diff --git a/deepagents_sourcecode/libs/deepagents/deepagents/graph.py b/deepagents_sourcecode/libs/deepagents/deepagents/graph.py index 1eda316..2a6ef20 100644 --- a/deepagents_sourcecode/libs/deepagents/deepagents/graph.py +++ b/deepagents_sourcecode/libs/deepagents/deepagents/graph.py @@ -1,4 +1,4 @@ -"""Deepagents come with planning, filesystem, and subagents.""" +"""Deepagents는 계획, 파일 시스템, 서브에이전트 기능을 제공합니다.""" from collections.abc import Callable, Sequence from typing import Any @@ -30,10 +30,10 @@ BASE_AGENT_PROMPT = "In order to complete the objective that the user asks of yo def get_default_model() -> ChatAnthropic: - """Get the default model for deep agents. + """Deep agents의 기본 모델을 가져옵니다. Returns: - `ChatAnthropic` instance configured with Claude Sonnet 4.5. + Claude Sonnet 4.5로 구성된 `ChatAnthropic` 인스턴스. """ return ChatAnthropic( model_name="claude-sonnet-4-5-20250929", @@ -60,56 +60,53 @@ def create_deep_agent( name: str | None = None, cache: BaseCache | None = None, ) -> CompiledStateGraph: - """Create a deep agent. + """DeepAgent를 생성합니다. - This agent will by default have access to a tool to write todos (`write_todos`), - seven file and execution tools: `ls`, `read_file`, `write_file`, `edit_file`, `glob`, `grep`, `execute`, - and a tool to call subagents. + 이 에이전트는 기본적으로 아래 기능(도구/미들웨어)을 포함합니다. + - todo 작성 도구: `write_todos` + - 파일/실행 도구: `ls`, `read_file`, `write_file`, `edit_file`, `glob`, `grep`, `execute` + - 서브에이전트 호출 도구 - The `execute` tool allows running shell commands if the backend implements `SandboxBackendProtocol`. - For non-sandbox backends, the `execute` tool will return an error message. + `execute` 도구는 backend가 `SandboxBackendProtocol`을 구현할 때 셸 커맨드를 실행할 수 있습니다. + 샌드박스가 아닌 backend에서는 `execute`가 오류 메시지를 반환합니다. Args: - model: The model to use. Defaults to `claude-sonnet-4-5-20250929`. - tools: The tools the agent should have access to. - system_prompt: The additional instructions the agent should have. Will go in - the system prompt. - middleware: Additional middleware to apply after standard middleware. - subagents: The subagents to use. - - Each subagent should be a `dict` with the following keys: + model: 사용할 모델. 기본값은 `claude-sonnet-4-5-20250929`. + tools: 에이전트에 추가로 제공할 도구 목록. + system_prompt: 에이전트에 추가로 주입할 지침. system prompt에 포함됩니다. + middleware: 표준 미들웨어 뒤에 추가로 적용할 미들웨어 목록. + subagents: 사용할 서브에이전트 정의 목록. + 각 서브에이전트는 아래 키를 가진 `dict` 형태입니다. - `name` - - `description` (used by the main agent to decide whether to call the sub agent) - - `prompt` (used as the system prompt in the subagent) + - `description` (메인 에이전트가 어떤 서브에이전트를 호출할지 결정할 때 사용) + - `prompt` (서브에이전트의 system prompt로 사용) - (optional) `tools` - - (optional) `model` (either a `LanguageModelLike` instance or `dict` settings) - - (optional) `middleware` (list of `AgentMiddleware`) - skills: Optional list of skill source paths (e.g., `["/skills/user/", "/skills/project/"]`). + - (optional) `model` (`LanguageModelLike` 인스턴스 또는 설정 `dict`) + - (optional) `middleware` (`AgentMiddleware` 리스트) + skills: 스킬 소스 경로 목록(예: `["/skills/user/", "/skills/project/"]`) (선택). - Paths must be specified using POSIX conventions (forward slashes) and are relative - to the backend's root. When using `StateBackend` (default), provide skill files via - `invoke(files={...})`. With `FilesystemBackend`, skills are loaded from disk relative - to the backend's `root_dir`. Later sources override earlier ones for skills with the - same name (last one wins). - memory: Optional list of memory file paths (`AGENTS.md` files) to load - (e.g., `["/memory/AGENTS.md"]`). Display names are automatically derived from paths. - Memory is loaded at agent startup and added into the system prompt. - response_format: A structured output response format to use for the agent. - context_schema: The schema of the deep agent. - checkpointer: Optional `Checkpointer` for persisting agent state between runs. - store: Optional store for persistent storage (required if backend uses `StoreBackend`). - backend: Optional backend for file storage and execution. + 경로는 POSIX 형식(슬래시 `/`)으로 지정하며 backend root 기준 상대 경로입니다. + `StateBackend`(기본값)를 사용할 때는 `invoke(files={...})`로 파일을 제공해야 합니다. + `FilesystemBackend`에서는 backend의 `root_dir` 기준으로 디스크에서 스킬을 로드합니다. + 같은 이름의 스킬이 중복될 경우 뒤에 오는 소스가 우선합니다(last one wins). + memory: 로드할 메모리 파일(AGENTS.md) 경로 목록(예: `["/memory/AGENTS.md"]`) (선택). + 표시 이름은 경로에서 자동 유도되며, 에이전트 시작 시 로드되어 system prompt에 포함됩니다. + response_format: 구조화 출력 응답 포맷(선택). + context_schema: DeepAgent의 컨텍스트 스키마(선택). + checkpointer: 실행 간 state를 저장하기 위한 `Checkpointer`(선택). + store: 영구 저장을 위한 store(선택). backend가 `StoreBackend`를 사용할 경우 필요합니다. + backend: 파일 저장/실행을 위한 backend(선택). - Pass either a `Backend` instance or a callable factory like `lambda rt: StateBackend(rt)`. - For execution support, use a backend that implements `SandboxBackendProtocol`. - interrupt_on: Mapping of tool names to interrupt configs. - debug: Whether to enable debug mode. Passed through to `create_agent`. - name: The name of the agent. Passed through to `create_agent`. - cache: The cache to use for the agent. Passed through to `create_agent`. + `Backend` 인스턴스 또는 `lambda rt: StateBackend(rt)` 같은 팩토리 함수를 전달할 수 있습니다. + 실행 지원이 필요하면 `SandboxBackendProtocol`을 구현한 backend를 사용하세요. + interrupt_on: 도구 이름 → interrupt 설정 매핑(선택). + debug: debug 모드 활성화 여부. `create_agent`로 전달됩니다. + name: 에이전트 이름. `create_agent`로 전달됩니다. + cache: 캐시 인스턴스. `create_agent`로 전달됩니다. Returns: - A configured deep agent. + 설정된(compiled) deep agent 그래프. """ if model is None: model = get_default_model() @@ -128,7 +125,7 @@ def create_deep_agent( trigger = ("tokens", 170000) keep = ("messages", 6) - # Build middleware stack for subagents (includes skills if provided) + # 서브에이전트용 미들웨어 스택 구성(skills가 있으면 포함) subagent_middleware: list[AgentMiddleware] = [ TodoListMiddleware(), ] @@ -151,7 +148,7 @@ def create_deep_agent( ] ) - # Build main agent middleware stack + # 메인 에이전트 미들웨어 스택 구성 deepagent_middleware: list[AgentMiddleware] = [ TodoListMiddleware(), ] diff --git a/deepagents_sourcecode/libs/deepagents/deepagents/middleware/__init__.py b/deepagents_sourcecode/libs/deepagents/deepagents/middleware/__init__.py index 59be940..7041868 100644 --- a/deepagents_sourcecode/libs/deepagents/deepagents/middleware/__init__.py +++ b/deepagents_sourcecode/libs/deepagents/deepagents/middleware/__init__.py @@ -1,4 +1,4 @@ -"""Middleware for the DeepAgent.""" +"""DeepAgent에 사용되는 미들웨어 모듈입니다.""" from deepagents.middleware.filesystem import FilesystemMiddleware from deepagents.middleware.memory import MemoryMiddleware diff --git a/deepagents_sourcecode/libs/deepagents/deepagents/middleware/filesystem.py b/deepagents_sourcecode/libs/deepagents/deepagents/middleware/filesystem.py index 39e4bdd..f7ffd6d 100644 --- a/deepagents_sourcecode/libs/deepagents/deepagents/middleware/filesystem.py +++ b/deepagents_sourcecode/libs/deepagents/deepagents/middleware/filesystem.py @@ -1,4 +1,4 @@ -"""Middleware for providing filesystem tools to an agent.""" +"""에이전트에 파일 시스템 도구를 제공하는 미들웨어입니다.""" # ruff: noqa: E501 import os @@ -44,24 +44,24 @@ DEFAULT_READ_LIMIT = 500 class FileData(TypedDict): - """Data structure for storing file contents with metadata.""" + """파일 내용을 메타데이터와 함께 저장하기 위한 데이터 구조입니다.""" content: list[str] - """Lines of the file.""" + """파일의 각 라인.""" created_at: str - """ISO 8601 timestamp of file creation.""" + """파일 생성 시각(ISO 8601).""" modified_at: str - """ISO 8601 timestamp of last modification.""" + """파일 마지막 수정 시각(ISO 8601).""" def _file_data_reducer(left: dict[str, FileData] | None, right: dict[str, FileData | None]) -> dict[str, FileData]: - """Merge file updates with support for deletions. + """파일 업데이트를 병합하며, 삭제를 지원합니다. - This reducer enables file deletion by treating `None` values in the right - dictionary as deletion markers. It's designed to work with LangGraph's - state management where annotated reducers control how state updates merge. + 오른쪽 딕셔너리의 값이 `None`인 엔트리를 “삭제 마커”로 취급해 삭제를 구현합니다. + LangGraph의 state 관리에서 annotated reducer가 state 업데이트 병합 방식을 제어한다는 + 전제에 맞춰 설계되었습니다. Args: left: Existing files dictionary. May be `None` during initialization. @@ -93,15 +93,13 @@ def _file_data_reducer(left: dict[str, FileData] | None, right: dict[str, FileDa def _validate_path(path: str, *, allowed_prefixes: Sequence[str] | None = None) -> str: - r"""Validate and normalize file path for security. + r"""보안 관점에서 파일 경로를 검증하고 정규화합니다. - Ensures paths are safe to use by preventing directory traversal attacks - and enforcing consistent formatting. All paths are normalized to use - forward slashes and start with a leading slash. + 디렉토리 트래버설 공격을 방지하고, 일관된 포맷을 강제하여 안전한 경로만 사용하도록 합니다. + 모든 경로는 `/`로 시작하며, 경로 구분자는 forward slash(`/`)로 정규화됩니다. - This function is designed for virtual filesystem paths and rejects - Windows absolute paths (e.g., C:/..., F:/...) to maintain consistency - and prevent path format ambiguity. + 이 함수는 “가상 파일시스템 경로(virtual paths)”를 대상으로 설계되었으며, + 경로 형식의 모호함을 피하기 위해 Windows 절대 경로(예: `C:/...`, `F:/...`)는 거부합니다. Args: path: The path to validate and normalize. @@ -130,8 +128,8 @@ def _validate_path(path: str, *, allowed_prefixes: Sequence[str] | None = None) msg = f"Path traversal not allowed: {path}" raise ValueError(msg) - # Reject Windows absolute paths (e.g., C:\..., D:/...) - # This maintains consistency in virtual filesystem paths + # Windows 절대 경로(예: C:\..., D:/...)는 거부합니다. + # 가상 파일시스템 경로 포맷의 일관성을 유지하기 위함입니다. if re.match(r"^[a-zA-Z]:", path): msg = f"Windows absolute paths are not supported: {path}. Please use virtual paths starting with / (e.g., /workspace/file.txt)" raise ValueError(msg) @@ -150,10 +148,10 @@ def _validate_path(path: str, *, allowed_prefixes: Sequence[str] | None = None) class FilesystemState(AgentState): - """State for the filesystem middleware.""" + """FilesystemMiddleware의 state 스키마입니다.""" files: Annotated[NotRequired[dict[str, FileData]], _file_data_reducer] - """Files in the filesystem.""" + """파일 시스템에 저장된 파일들.""" LIST_FILES_TOOL_DESCRIPTION = """Lists all files in the filesystem, filtering by directory. @@ -296,14 +294,14 @@ Use this tool to run commands, scripts, tests, builds, and other shell operation def _get_backend(backend: BACKEND_TYPES, runtime: ToolRuntime) -> BackendProtocol: - """Get the resolved backend instance from backend or factory. + """백엔드 또는 팩토리에서 해결된 백엔드 인스턴스를 가져옵니다. Args: - backend: Backend instance or factory function. - runtime: The tool runtime context. + backend: 백엔드 인스턴스 또는 팩토리 함수. + runtime: 도구 런타임 컨텍스트. Returns: - Resolved backend instance. + 해결된 백엔드 인스턴스. """ if callable(backend): return backend(runtime) @@ -314,19 +312,19 @@ def _ls_tool_generator( backend: BackendProtocol | Callable[[ToolRuntime], BackendProtocol], custom_description: str | None = None, ) -> BaseTool: - """Generate the ls (list files) tool. + """파일 목록(ls) 도구를 생성합니다. Args: - backend: Backend to use for file storage, or a factory function that takes runtime and returns a backend. - custom_description: Optional custom description for the tool. + backend: 파일 저장에 사용할 백엔드, 또는 런타임을 받아 백엔드를 반환하는 팩토리 함수. + custom_description: 도구의 선택적 사용자 정의 설명. Returns: - Configured ls tool that lists files using the backend. + 백엔드를 사용하여 파일을 나열하는 구성된 ls 도구. """ tool_description = custom_description or LIST_FILES_TOOL_DESCRIPTION def sync_ls(runtime: ToolRuntime[None, FilesystemState], path: str) -> str: - """Synchronous wrapper for ls tool.""" + """파일 목록(ls) 도구의 동기 래퍼입니다.""" resolved_backend = _get_backend(backend, runtime) validated_path = _validate_path(path) infos = resolved_backend.ls_info(validated_path) @@ -335,7 +333,7 @@ def _ls_tool_generator( return str(result) async def async_ls(runtime: ToolRuntime[None, FilesystemState], path: str) -> str: - """Asynchronous wrapper for ls tool.""" + """파일 목록(ls) 도구의 비동기 래퍼입니다.""" resolved_backend = _get_backend(backend, runtime) validated_path = _validate_path(path) infos = await resolved_backend.als_info(validated_path) @@ -355,14 +353,14 @@ def _read_file_tool_generator( backend: BackendProtocol | Callable[[ToolRuntime], BackendProtocol], custom_description: str | None = None, ) -> BaseTool: - """Generate the read_file tool. + """`read_file` 도구를 생성합니다. Args: - backend: Backend to use for file storage, or a factory function that takes runtime and returns a backend. - custom_description: Optional custom description for the tool. + backend: 파일 저장에 사용할 백엔드 또는 (runtime을 받아 백엔드를 반환하는) 팩토리 함수. + custom_description: 도구 설명을 커스텀할 때 사용(선택). Returns: - Configured read_file tool that reads files using the backend. + backend를 통해 파일을 읽는 `read_file` 도구. """ tool_description = custom_description or READ_FILE_TOOL_DESCRIPTION @@ -372,7 +370,7 @@ def _read_file_tool_generator( offset: int = DEFAULT_READ_OFFSET, limit: int = DEFAULT_READ_LIMIT, ) -> str: - """Synchronous wrapper for read_file tool.""" + """`read_file` 도구의 동기 래퍼입니다.""" resolved_backend = _get_backend(backend, runtime) file_path = _validate_path(file_path) return resolved_backend.read(file_path, offset=offset, limit=limit) @@ -383,7 +381,7 @@ def _read_file_tool_generator( offset: int = DEFAULT_READ_OFFSET, limit: int = DEFAULT_READ_LIMIT, ) -> str: - """Asynchronous wrapper for read_file tool.""" + """`read_file` 도구의 비동기 래퍼입니다.""" resolved_backend = _get_backend(backend, runtime) file_path = _validate_path(file_path) return await resolved_backend.aread(file_path, offset=offset, limit=limit) @@ -400,14 +398,14 @@ def _write_file_tool_generator( backend: BackendProtocol | Callable[[ToolRuntime], BackendProtocol], custom_description: str | None = None, ) -> BaseTool: - """Generate the write_file tool. + """`write_file` 도구를 생성합니다. Args: - backend: Backend to use for file storage, or a factory function that takes runtime and returns a backend. - custom_description: Optional custom description for the tool. + backend: 파일 저장에 사용할 백엔드 또는 (runtime을 받아 백엔드를 반환하는) 팩토리 함수. + custom_description: 도구 설명을 커스텀할 때 사용(선택). Returns: - Configured write_file tool that creates new files using the backend. + backend를 통해 새 파일을 생성하는 `write_file` 도구. """ tool_description = custom_description or WRITE_FILE_TOOL_DESCRIPTION @@ -416,13 +414,13 @@ def _write_file_tool_generator( content: str, runtime: ToolRuntime[None, FilesystemState], ) -> Command | str: - """Synchronous wrapper for write_file tool.""" + """`write_file` 도구의 동기 래퍼입니다.""" resolved_backend = _get_backend(backend, runtime) file_path = _validate_path(file_path) res: WriteResult = resolved_backend.write(file_path, content) if res.error: return res.error - # If backend returns state update, wrap into Command with ToolMessage + # backend가 state 업데이트를 반환하면, ToolMessage와 함께 Command로 감쌉니다. if res.files_update is not None: return Command( update={ @@ -442,13 +440,13 @@ def _write_file_tool_generator( content: str, runtime: ToolRuntime[None, FilesystemState], ) -> Command | str: - """Asynchronous wrapper for write_file tool.""" + """`write_file` 도구의 비동기 래퍼입니다.""" resolved_backend = _get_backend(backend, runtime) file_path = _validate_path(file_path) res: WriteResult = await resolved_backend.awrite(file_path, content) if res.error: return res.error - # If backend returns state update, wrap into Command with ToolMessage + # backend가 state 업데이트를 반환하면, ToolMessage와 함께 Command로 감쌉니다. if res.files_update is not None: return Command( update={ @@ -475,14 +473,14 @@ def _edit_file_tool_generator( backend: BackendProtocol | Callable[[ToolRuntime], BackendProtocol], custom_description: str | None = None, ) -> BaseTool: - """Generate the edit_file tool. + """`edit_file` 도구를 생성합니다. Args: - backend: Backend to use for file storage, or a factory function that takes runtime and returns a backend. - custom_description: Optional custom description for the tool. + backend: 파일 저장에 사용할 백엔드 또는 (runtime을 받아 백엔드를 반환하는) 팩토리 함수. + custom_description: 도구 설명을 커스텀할 때 사용(선택). Returns: - Configured edit_file tool that performs string replacements in files using the backend. + backend를 통해 파일 내 문자열 치환을 수행하는 `edit_file` 도구. """ tool_description = custom_description or EDIT_FILE_TOOL_DESCRIPTION @@ -494,7 +492,7 @@ def _edit_file_tool_generator( *, replace_all: bool = False, ) -> Command | str: - """Synchronous wrapper for edit_file tool.""" + """`edit_file` 도구의 동기 래퍼입니다.""" resolved_backend = _get_backend(backend, runtime) file_path = _validate_path(file_path) res: EditResult = resolved_backend.edit(file_path, old_string, new_string, replace_all=replace_all) @@ -522,7 +520,7 @@ def _edit_file_tool_generator( *, replace_all: bool = False, ) -> Command | str: - """Asynchronous wrapper for edit_file tool.""" + """`edit_file` 도구의 비동기 래퍼입니다.""" resolved_backend = _get_backend(backend, runtime) file_path = _validate_path(file_path) res: EditResult = await resolved_backend.aedit(file_path, old_string, new_string, replace_all=replace_all) @@ -554,19 +552,19 @@ def _glob_tool_generator( backend: BackendProtocol | Callable[[ToolRuntime], BackendProtocol], custom_description: str | None = None, ) -> BaseTool: - """Generate the glob tool. + """`glob` 도구를 생성합니다. Args: - backend: Backend to use for file storage, or a factory function that takes runtime and returns a backend. - custom_description: Optional custom description for the tool. + backend: 파일 저장에 사용할 백엔드 또는 (runtime을 받아 백엔드를 반환하는) 팩토리 함수. + custom_description: 도구 설명을 커스텀할 때 사용(선택). Returns: - Configured glob tool that finds files by pattern using the backend. + backend를 통해 패턴 매칭으로 파일을 찾는 `glob` 도구. """ tool_description = custom_description or GLOB_TOOL_DESCRIPTION def sync_glob(pattern: str, runtime: ToolRuntime[None, FilesystemState], path: str = "/") -> str: - """Synchronous wrapper for glob tool.""" + """`glob` 도구의 동기 래퍼입니다.""" resolved_backend = _get_backend(backend, runtime) infos = resolved_backend.glob_info(pattern, path=path) paths = [fi.get("path", "") for fi in infos] @@ -574,7 +572,7 @@ def _glob_tool_generator( return str(result) async def async_glob(pattern: str, runtime: ToolRuntime[None, FilesystemState], path: str = "/") -> str: - """Asynchronous wrapper for glob tool.""" + """`glob` 도구의 비동기 래퍼입니다.""" resolved_backend = _get_backend(backend, runtime) infos = await resolved_backend.aglob_info(pattern, path=path) paths = [fi.get("path", "") for fi in infos] @@ -593,14 +591,14 @@ def _grep_tool_generator( backend: BackendProtocol | Callable[[ToolRuntime], BackendProtocol], custom_description: str | None = None, ) -> BaseTool: - """Generate the grep tool. + """`grep` 도구를 생성합니다. Args: - backend: Backend to use for file storage, or a factory function that takes runtime and returns a backend. - custom_description: Optional custom description for the tool. + backend: 파일 저장에 사용할 백엔드 또는 (runtime을 받아 백엔드를 반환하는) 팩토리 함수. + custom_description: 도구 설명을 커스텀할 때 사용(선택). Returns: - Configured grep tool that searches for patterns in files using the backend. + backend를 통해 파일 내 패턴 검색을 수행하는 `grep` 도구. """ tool_description = custom_description or GREP_TOOL_DESCRIPTION @@ -611,7 +609,7 @@ def _grep_tool_generator( glob: str | None = None, output_mode: Literal["files_with_matches", "content", "count"] = "files_with_matches", ) -> str: - """Synchronous wrapper for grep tool.""" + """`grep` 도구의 동기 래퍼입니다.""" resolved_backend = _get_backend(backend, runtime) raw = resolved_backend.grep_raw(pattern, path=path, glob=glob) if isinstance(raw, str): @@ -626,7 +624,7 @@ def _grep_tool_generator( glob: str | None = None, output_mode: Literal["files_with_matches", "content", "count"] = "files_with_matches", ) -> str: - """Asynchronous wrapper for grep tool.""" + """`grep` 도구의 비동기 래퍼입니다.""" resolved_backend = _get_backend(backend, runtime) raw = await resolved_backend.agrep_raw(pattern, path=path, glob=glob) if isinstance(raw, str): @@ -643,25 +641,25 @@ def _grep_tool_generator( def _supports_execution(backend: BackendProtocol) -> bool: - """Check if a backend supports command execution. + """backend가 커맨드 실행을 지원하는지 확인합니다. - For CompositeBackend, checks if the default backend supports execution. - For other backends, checks if they implement SandboxBackendProtocol. + - `CompositeBackend`인 경우: `default` backend가 실행을 지원하는지 확인합니다. + - 그 외의 경우: `SandboxBackendProtocol` 구현 여부로 판단합니다. Args: - backend: The backend to check. + backend: 확인할 backend. Returns: - True if the backend supports execution, False otherwise. + 실행을 지원하면 `True`, 아니면 `False`. """ - # Import here to avoid circular dependency + # 순환 의존(circular dependency)을 피하기 위해 여기서 import 합니다. from deepagents.backends.composite import CompositeBackend - # For CompositeBackend, check the default backend + # CompositeBackend는 default backend가 실행을 지원하는지 확인합니다. if isinstance(backend, CompositeBackend): return isinstance(backend.default, SandboxBackendProtocol) - # For other backends, use isinstance check + # 그 외 backend는 isinstance로 실행 지원 여부를 판단합니다. return isinstance(backend, SandboxBackendProtocol) @@ -669,14 +667,14 @@ def _execute_tool_generator( backend: BackendProtocol | Callable[[ToolRuntime], BackendProtocol], custom_description: str | None = None, ) -> BaseTool: - """Generate the execute tool for sandbox command execution. + """샌드박스 커맨드 실행을 위한 `execute` 도구를 생성합니다. Args: - backend: Backend to use for execution, or a factory function that takes runtime and returns a backend. - custom_description: Optional custom description for the tool. + backend: 실행에 사용할 backend 또는 (runtime을 받아 backend를 반환하는) 팩토리 함수. + custom_description: 도구 설명을 커스텀할 때 사용(선택). Returns: - Configured execute tool that runs commands if backend supports SandboxBackendProtocol. + backend가 `SandboxBackendProtocol`을 지원할 때 커맨드를 실행하는 `execute` 도구. """ tool_description = custom_description or EXECUTE_TOOL_DESCRIPTION @@ -684,10 +682,10 @@ def _execute_tool_generator( command: str, runtime: ToolRuntime[None, FilesystemState], ) -> str: - """Synchronous wrapper for execute tool.""" + """`execute` 도구의 동기 래퍼입니다.""" resolved_backend = _get_backend(backend, runtime) - # Runtime check - fail gracefully if not supported + # 런타임 체크: 지원하지 않으면 명시적인 오류 메시지로 종료 if not _supports_execution(resolved_backend): return ( "Error: Execution not available. This agent's backend " @@ -698,10 +696,10 @@ def _execute_tool_generator( try: result = resolved_backend.execute(command) except NotImplementedError as e: - # Handle case where execute() exists but raises NotImplementedError + # execute()가 존재하지만 NotImplementedError를 던지는 케이스 처리 return f"Error: Execution not available. {e}" - # Format output for LLM consumption + # (LLM 입력으로 쓰기 좋게) 출력 포맷팅 parts = [result.output] if result.exit_code is not None: @@ -717,10 +715,10 @@ def _execute_tool_generator( command: str, runtime: ToolRuntime[None, FilesystemState], ) -> str: - """Asynchronous wrapper for execute tool.""" + """`execute` 도구의 비동기 래퍼입니다.""" resolved_backend = _get_backend(backend, runtime) - # Runtime check - fail gracefully if not supported + # 런타임 체크: 지원하지 않으면 명시적인 오류 메시지로 종료 if not _supports_execution(resolved_backend): return ( "Error: Execution not available. This agent's backend " @@ -731,10 +729,10 @@ def _execute_tool_generator( try: result = await resolved_backend.aexecute(command) except NotImplementedError as e: - # Handle case where execute() exists but raises NotImplementedError + # execute()가 존재하지만 NotImplementedError를 던지는 케이스 처리 return f"Error: Execution not available. {e}" - # Format output for LLM consumption + # (LLM 입력으로 쓰기 좋게) 출력 포맷팅 parts = [result.output] if result.exit_code is not None: @@ -769,14 +767,14 @@ def _get_filesystem_tools( backend: BackendProtocol, custom_tool_descriptions: dict[str, str] | None = None, ) -> list[BaseTool]: - """Get filesystem and execution tools. + """파일 시스템 도구(및 가능한 경우 실행 도구)를 구성해 반환합니다. Args: - backend: Backend to use for file storage and optional execution, or a factory function that takes runtime and returns a backend. - custom_tool_descriptions: Optional custom descriptions for tools. + backend: 파일 저장(및 선택적 실행)에 사용할 backend. + custom_tool_descriptions: 도구별 커스텀 설명(선택). Returns: - List of configured tools: ls, read_file, write_file, edit_file, glob, grep, execute. + 구성된 도구 리스트: `ls`, `read_file`, `write_file`, `edit_file`, `glob`, `grep`, `execute`. """ if custom_tool_descriptions is None: custom_tool_descriptions = {} @@ -799,23 +797,22 @@ Here are the first 10 lines of the result: class FilesystemMiddleware(AgentMiddleware): - """Middleware for providing filesystem and optional execution tools to an agent. + """에이전트에 파일 시스템 도구(및 선택적 실행 도구)를 제공하는 미들웨어입니다. - This middleware adds filesystem tools to the agent: ls, read_file, write_file, - edit_file, glob, and grep. Files can be stored using any backend that implements - the BackendProtocol. + 이 미들웨어는 에이전트에 아래 도구들을 추가합니다. + - 파일 시스템 도구: `ls`, `read_file`, `write_file`, `edit_file`, `glob`, `grep` + - (선택) 실행 도구: `execute` (backend가 `SandboxBackendProtocol`을 구현할 때) - If the backend implements SandboxBackendProtocol, an execute tool is also added - for running shell commands. + 파일 저장은 `BackendProtocol`을 구현하는 어떤 backend든 사용할 수 있습니다. Args: - backend: Backend for file storage and optional execution. If not provided, defaults to StateBackend - (ephemeral storage in agent state). For persistent storage or hybrid setups, - use CompositeBackend with custom routes. For execution support, use a backend - that implements SandboxBackendProtocol. - system_prompt: Optional custom system prompt override. - custom_tool_descriptions: Optional custom tool descriptions override. - tool_token_limit_before_evict: Optional token limit before evicting a tool result to the filesystem. + backend: 파일 저장(및 선택적 실행)에 사용할 backend. 미지정 시 `StateBackend`를 기본값으로 사용합니다 + (에이전트 state에 저장되는 일시적(ephemeral) 스토리지). + 영구 저장이나 하이브리드 구성이 필요하면 route를 설정한 `CompositeBackend`를 사용하세요. + 커맨드 실행이 필요하면 `SandboxBackendProtocol`을 구현한 backend를 사용해야 합니다. + system_prompt: 커스텀 system prompt 오버라이드(선택). + custom_tool_descriptions: 도구 설명 오버라이드(선택). + tool_token_limit_before_evict: tool 결과를 파일 시스템으로 축출(evict)하기 전 토큰 제한(선택). Example: ```python @@ -823,14 +820,14 @@ class FilesystemMiddleware(AgentMiddleware): from deepagents.backends import StateBackend, StoreBackend, CompositeBackend from langchain.agents import create_agent - # Ephemeral storage only (default, no execution) + # 일시적 저장만 사용(기본값, 실행 도구 없음) agent = create_agent(middleware=[FilesystemMiddleware()]) - # With hybrid storage (ephemeral + persistent /memories/) + # 하이브리드 저장(일시적 + /memories/ 영구 저장) backend = CompositeBackend(default=StateBackend(), routes={"/memories/": StoreBackend()}) agent = create_agent(middleware=[FilesystemMiddleware(backend=backend)]) - # With sandbox backend (supports execution) + # 샌드박스 backend(실행 도구 지원) from my_sandbox import DockerSandboxBackend sandbox = DockerSandboxBackend(container_id="my-container") @@ -848,33 +845,33 @@ class FilesystemMiddleware(AgentMiddleware): custom_tool_descriptions: dict[str, str] | None = None, tool_token_limit_before_evict: int | None = 20000, ) -> None: - """Initialize the filesystem middleware. + """파일 시스템 미들웨어를 초기화합니다. Args: - backend: Backend for file storage and optional execution, or a factory callable. - Defaults to StateBackend if not provided. - system_prompt: Optional custom system prompt override. - custom_tool_descriptions: Optional custom tool descriptions override. - tool_token_limit_before_evict: Optional token limit before evicting a tool result to the filesystem. + backend: 파일 저장/실행에 사용할 backend 또는 팩토리 callable. + 미지정 시 `StateBackend`를 기본값으로 사용합니다. + system_prompt: 커스텀 system prompt 오버라이드(선택). + custom_tool_descriptions: 도구 설명 오버라이드(선택). + tool_token_limit_before_evict: tool 결과를 파일 시스템으로 축출(evict)하기 전 토큰 제한(선택). """ self.tool_token_limit_before_evict = tool_token_limit_before_evict - # Use provided backend or default to StateBackend factory + # backend가 주어지지 않으면 StateBackend 팩토리를 기본값으로 사용 self.backend = backend if backend is not None else (lambda rt: StateBackend(rt)) - # Set system prompt (allow full override or None to generate dynamically) + # system prompt 설정(완전 오버라이드 또는 None이면 동적 생성) self._custom_system_prompt = system_prompt self.tools = _get_filesystem_tools(self.backend, custom_tool_descriptions) def _get_backend(self, runtime: ToolRuntime) -> BackendProtocol: - """Get the resolved backend instance from backend or factory. + """백엔드 인스턴스/팩토리로부터 실제 백엔드를 해석(resolve)합니다. Args: - runtime: The tool runtime context. + runtime: tool runtime 컨텍스트. Returns: - Resolved backend instance. + 해석된 backend 인스턴스. """ if callable(self.backend): return self.backend(runtime) @@ -885,38 +882,38 @@ class FilesystemMiddleware(AgentMiddleware): request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse], ) -> ModelResponse: - """Update the system prompt and filter tools based on backend capabilities. + """백엔드 capability에 따라 system prompt/도구 목록을 갱신합니다. Args: - request: The model request being processed. - handler: The handler function to call with the modified request. + request: 처리 중인 모델 요청. + handler: 수정된 요청으로 호출할 핸들러 함수. Returns: - The model response from the handler. + 핸들러가 반환한 모델 응답. """ - # Check if execute tool is present and if backend supports it + # execute 도구가 있는지, 그리고 backend가 실행을 지원하는지 확인 has_execute_tool = any((tool.name if hasattr(tool, "name") else tool.get("name")) == "execute" for tool in request.tools) backend_supports_execution = False if has_execute_tool: - # Resolve backend to check execution support + # 실행 지원 여부를 확인하기 위해 backend를 해석 backend = self._get_backend(request.runtime) backend_supports_execution = _supports_execution(backend) - # If execute tool exists but backend doesn't support it, filter it out + # execute 도구가 있지만 backend가 지원하지 않으면 tools에서 제거 if not backend_supports_execution: filtered_tools = [tool for tool in request.tools if (tool.name if hasattr(tool, "name") else tool.get("name")) != "execute"] request = request.override(tools=filtered_tools) has_execute_tool = False - # Use custom system prompt if provided, otherwise generate dynamically + # 커스텀 system prompt가 있으면 사용하고, 없으면 사용 가능한 도구 기준으로 동적 생성 if self._custom_system_prompt is not None: system_prompt = self._custom_system_prompt else: - # Build dynamic system prompt based on available tools + # 사용 가능한 도구에 따라 동적 system prompt 구성 prompt_parts = [FILESYSTEM_SYSTEM_PROMPT] - # Add execution instructions if execute tool is available + # execute 도구가 가능하면 실행 관련 지침 추가 if has_execute_tool and backend_supports_execution: prompt_parts.append(EXECUTION_SYSTEM_PROMPT) @@ -932,7 +929,7 @@ class FilesystemMiddleware(AgentMiddleware): request: ModelRequest, handler: Callable[[ModelRequest], Awaitable[ModelResponse]], ) -> ModelResponse: - """(async) Update the system prompt and filter tools based on backend capabilities. + """(async) backend capability에 따라 system prompt/도구 목록을 갱신합니다. Args: request: The model request being processed. @@ -979,7 +976,7 @@ class FilesystemMiddleware(AgentMiddleware): message: ToolMessage, resolved_backend: BackendProtocol, ) -> tuple[ToolMessage, dict[str, FileData] | None]: - """Process a large ToolMessage by evicting its content to filesystem. + """큰 ToolMessage를 처리하며, 콘텐츠를 파일 시스템으로 축출(evict)합니다. Args: message: The ToolMessage with large content to evict. @@ -999,12 +996,12 @@ class FilesystemMiddleware(AgentMiddleware): uncommon in tool results. For simplicity, all content is stringified and evicted. The model can recover by reading the offloaded file from the backend. """ - # Early exit if eviction not configured + # 축출 설정이 없으면 조기 종료 if not self.tool_token_limit_before_evict: return message, None - # Convert content to string once for both size check and eviction - # Special case: single text block - extract text directly for readability + # 크기 체크와 축출을 위해 콘텐츠를 한 번만 문자열로 변환합니다. + # 특수 케이스: 단일 텍스트 블록이면 가독성을 위해 텍스트만 추출합니다. if ( isinstance(message.content, list) and len(message.content) == 1 @@ -1016,23 +1013,23 @@ class FilesystemMiddleware(AgentMiddleware): elif isinstance(message.content, str): content_str = message.content else: - # Multiple blocks or non-text content - stringify entire structure + # 여러 블록 또는 텍스트가 아닌 콘텐츠: 전체 구조를 문자열로 변환 content_str = str(message.content) - # Check if content exceeds eviction threshold - # Using 4 chars per token as a conservative approximation (actual ratio varies by content) - # This errs on the high side to avoid premature eviction of content that might fit + # 콘텐츠가 축출 임계치를 초과하는지 확인 + # token당 4 chars로 보수적으로 추정합니다(실제 비율은 콘텐츠에 따라 달라짐). + # 실제로는 들어갈 수 있는 콘텐츠를 너무 일찍 축출하지 않도록 “높게” 잡는 쪽으로 동작합니다. if len(content_str) <= 4 * self.tool_token_limit_before_evict: return message, None - # Write content to filesystem + # 콘텐츠를 파일 시스템에 기록 sanitized_id = sanitize_tool_call_id(message.tool_call_id) file_path = f"/large_tool_results/{sanitized_id}" result = resolved_backend.write(file_path, content_str) if result.error: return message, None - # Create truncated preview for the replacement message + # 대체 메시지에 넣을 미리보기(트렁케이트) 생성 content_sample = format_content_with_line_numbers([line[:1000] for line in content_str.splitlines()[:10]], start_line=1) replacement_text = TOO_LARGE_TOOL_MSG.format( tool_call_id=message.tool_call_id, @@ -1040,7 +1037,7 @@ class FilesystemMiddleware(AgentMiddleware): content_sample=content_sample, ) - # Always return as plain string after eviction + # 축출 후에는 항상 plain string ToolMessage로 반환 processed_message = ToolMessage( content=replacement_text, tool_call_id=message.tool_call_id, @@ -1048,7 +1045,7 @@ class FilesystemMiddleware(AgentMiddleware): return processed_message, result.files_update def _intercept_large_tool_result(self, tool_result: ToolMessage | Command, runtime: ToolRuntime) -> ToolMessage | Command: - """Intercept and process large tool results before they're added to state. + """state에 추가되기 전에 큰 tool result를 가로채서 처리합니다. Args: tool_result: The tool result to potentially evict (ToolMessage or Command). @@ -1108,7 +1105,7 @@ class FilesystemMiddleware(AgentMiddleware): request: ToolCallRequest, handler: Callable[[ToolCallRequest], ToolMessage | Command], ) -> ToolMessage | Command: - """Check the size of the tool call result and evict to filesystem if too large. + """도구 호출(tool call) 결과 크기를 확인하고, 너무 크면 파일 시스템으로 축출(evict)합니다. Args: request: The tool call request being processed. @@ -1128,7 +1125,7 @@ class FilesystemMiddleware(AgentMiddleware): request: ToolCallRequest, handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]], ) -> ToolMessage | Command: - """(async)Check the size of the tool call result and evict to filesystem if too large. + """(async) tool call 결과 크기를 확인하고, 너무 크면 파일 시스템으로 축출(evict)합니다. Args: request: The tool call request being processed. diff --git a/deepagents_sourcecode/libs/deepagents/deepagents/middleware/memory.py b/deepagents_sourcecode/libs/deepagents/deepagents/middleware/memory.py index d6b03c1..5ba7583 100644 --- a/deepagents_sourcecode/libs/deepagents/deepagents/middleware/memory.py +++ b/deepagents_sourcecode/libs/deepagents/deepagents/middleware/memory.py @@ -1,23 +1,22 @@ -"""Middleware for loading agent memory/context from AGENTS.md files. +"""AGENTS.md 파일로부터 에이전트 메모리/컨텍스트를 로드하는 미들웨어입니다. -This module implements support for the AGENTS.md specification (https://agents.md/), -loading memory/context from configurable sources and injecting into the system prompt. +이 모듈은 AGENTS.md 사양(https://agents.md/)을 지원하여, 설정된 소스에서 메모리/컨텍스트를 +읽어들인 다음 system prompt에 주입(inject)합니다. -## Overview +## 개요 -AGENTS.md files provide project-specific context and instructions to help AI agents -work effectively. Unlike skills (which are on-demand workflows), memory is always -loaded and provides persistent context. +AGENTS.md는 프로젝트별 맥락과 지침을 제공하여 AI 에이전트가 더 안정적으로 작업하도록 돕습니다. +스킬(skills)이 “필요할 때만(on-demand) 호출되는 워크플로”라면, 메모리(memory)는 항상 로드되어 +지속적인(persistent) 컨텍스트를 제공합니다. -## Usage +## 사용 예시 ```python from deepagents import MemoryMiddleware from deepagents.backends.filesystem import FilesystemBackend -# Security: FilesystemBackend allows reading/writing from the entire filesystem. -# Either ensure the agent is running within a sandbox OR add human-in-the-loop (HIL) -# approval to file operations. +# 보안 주의: FilesystemBackend는 전체 파일시스템에 대한 읽기/쓰기 권한을 가질 수 있습니다. +# 에이전트가 샌드박스에서 실행되도록 하거나, 파일 작업에 human-in-the-loop(HIL) 승인을 붙이세요. backend = FilesystemBackend(root_dir="/") middleware = MemoryMiddleware( @@ -31,20 +30,18 @@ middleware = MemoryMiddleware( agent = create_deep_agent(middleware=[middleware]) ``` -## Memory Sources +## 메모리 소스(Memory Sources) -Sources are simply paths to AGENTS.md files that are loaded in order and combined. -Multiple sources are concatenated in order, with all content included. -Later sources appear after earlier ones in the combined prompt. +소스는 로드할 AGENTS.md 파일 경로 리스트입니다. 소스는 지정된 순서대로 읽혀 하나로 결합되며, +뒤에 오는 소스가 결합된 프롬프트의 뒤쪽에 붙습니다. -## File Format +## 파일 형식 -AGENTS.md files are standard Markdown with no required structure. -Common sections include: -- Project overview -- Build/test commands -- Code style guidelines -- Architecture notes +AGENTS.md는 일반적인 Markdown이며 필수 구조는 없습니다. 관례적으로는 아래 섹션이 자주 포함됩니다. +- 프로젝트 개요 +- 빌드/테스트 명령 +- 코드 스타일 가이드 +- 아키텍처 노트 """ from __future__ import annotations @@ -73,18 +70,18 @@ logger = logging.getLogger(__name__) class MemoryState(AgentState): - """State schema for MemoryMiddleware. + """MemoryMiddleware의 state 스키마입니다. Attributes: - memory_contents: Dict mapping source paths to their loaded content. - Marked as private so it's not included in the final agent state. + memory_contents: 소스 경로 → 로드된 콘텐츠 매핑. + 최종 agent state에 포함되지 않도록 private로 표시됩니다. """ memory_contents: NotRequired[Annotated[dict[str, str], PrivateStateAttr]] class MemoryStateUpdate(TypedDict): - """State update for MemoryMiddleware.""" + """MemoryMiddleware의 state 업데이트 타입입니다.""" memory_contents: dict[str, str] @@ -152,14 +149,14 @@ MEMORY_SYSTEM_PROMPT = """ class MemoryMiddleware(AgentMiddleware): - """Middleware for loading agent memory from AGENTS.md files. + """AGENTS.md 파일에서 에이전트 메모리를 로드하는 미들웨어입니다. - Loads memory content from configured sources and injects into the system prompt. - Supports multiple sources that are combined together. + 설정된 소스에서 메모리를 로드한 뒤 system prompt에 주입합니다. + 여러 소스를 결합하여 한 번에 주입하는 구성을 지원합니다. Args: - backend: Backend instance or factory function for file operations. - sources: List of MemorySource configurations specifying paths and names. + backend: 파일 작업을 위한 backend 인스턴스 또는 팩토리 함수. + sources: 로드할 AGENTS.md 파일 경로 리스트. """ state_schema = MemoryState @@ -170,31 +167,21 @@ class MemoryMiddleware(AgentMiddleware): backend: BACKEND_TYPES, sources: list[str], ) -> None: - """Initialize the memory middleware. + """메모리 미들웨어를 초기화합니다. Args: - backend: Backend instance or factory function that takes runtime - and returns a backend. Use a factory for StateBackend. - sources: List of memory file paths to load (e.g., ["~/.deepagents/AGENTS.md", - "./.deepagents/AGENTS.md"]). Display names are automatically derived - from the paths. Sources are loaded in order. + backend: backend 인스턴스 또는 (runtime을 받아 backend를 만드는) 팩토리 함수. + `StateBackend`를 사용하려면 팩토리 형태로 전달해야 합니다. + sources: 로드할 메모리 파일 경로 리스트(예: `["~/.deepagents/AGENTS.md", "./.deepagents/AGENTS.md"]`). + 표시 이름은 경로로부터 자동 유도됩니다. 소스는 지정 순서대로 로드됩니다. """ self._backend = backend self.sources = sources def _get_backend(self, state: MemoryState, runtime: Runtime, config: RunnableConfig) -> BackendProtocol: - """Resolve backend from instance or factory. - - Args: - state: Current agent state. - runtime: Runtime context for factory functions. - config: Runnable config to pass to backend factory. - - Returns: - Resolved backend instance. - """ + """Backend를 인스턴스 또는 팩토리로부터 해석(resolve)합니다.""" if callable(self._backend): - # Construct an artificial tool runtime to resolve backend factory + # backend 팩토리를 호출하기 위한 ToolRuntime을 구성합니다. tool_runtime = ToolRuntime( state=state, context=runtime.context, @@ -207,14 +194,7 @@ class MemoryMiddleware(AgentMiddleware): return self._backend def _format_agent_memory(self, contents: dict[str, str]) -> str: - """Format memory with locations and contents paired together. - - Args: - contents: Dict mapping source paths to content. - - Returns: - Formatted string with location+content pairs wrapped in tags. - """ + """메모리 소스 경로와 콘텐츠를 짝지어 포맷팅합니다.""" if not contents: return MEMORY_SYSTEM_PROMPT.format(agent_memory="(No memory loaded)") @@ -234,27 +214,19 @@ class MemoryMiddleware(AgentMiddleware): backend: BackendProtocol, path: str, ) -> str | None: - """Load memory content from a backend path. - - Args: - backend: Backend to load from. - path: Path to the AGENTS.md file. - - Returns: - File content if found, None otherwise. - """ + """backend에서 특정 경로의 메모리(AGENTS.md) 콘텐츠를 로드합니다.""" results = await backend.adownload_files([path]) - # Should get exactly one response for one path + # 단일 path에 대해 단일 응답이 와야 합니다. if len(results) != 1: raise AssertionError(f"Expected 1 response for path {path}, got {len(results)}") response = results[0] if response.error is not None: - # For now, memory files are treated as optional. file_not_found is expected - # and we skip silently to allow graceful degradation. + # 현재는 메모리 파일을 optional로 취급합니다. + # file_not_found는 정상적으로 발생할 수 있으므로 조용히 스킵하여 점진적 저하(graceful degradation)를 허용합니다. if response.error == "file_not_found": return None - # Other errors should be raised + # 그 외 오류는 예외로 올립니다. raise ValueError(f"Failed to download {path}: {response.error}") if response.content is not None: @@ -267,7 +239,7 @@ class MemoryMiddleware(AgentMiddleware): backend: BackendProtocol, path: str, ) -> str | None: - """Load memory content from a backend path synchronously. + """backend에서 특정 경로의 메모리(AGENTS.md) 콘텐츠를 동기로 로드합니다. Args: backend: Backend to load from. @@ -277,17 +249,17 @@ class MemoryMiddleware(AgentMiddleware): File content if found, None otherwise. """ results = backend.download_files([path]) - # Should get exactly one response for one path + # 단일 path에 대해 단일 응답이 와야 합니다. if len(results) != 1: raise AssertionError(f"Expected 1 response for path {path}, got {len(results)}") response = results[0] if response.error is not None: - # For now, memory files are treated as optional. file_not_found is expected - # and we skip silently to allow graceful degradation. + # 현재는 메모리 파일을 optional로 취급합니다. + # file_not_found는 정상적으로 발생할 수 있으므로 조용히 스킵하여 점진적 저하(graceful degradation)를 허용합니다. if response.error == "file_not_found": return None - # Other errors should be raised + # 그 외 오류는 예외로 올립니다. raise ValueError(f"Failed to download {path}: {response.error}") if response.content is not None: @@ -296,7 +268,7 @@ class MemoryMiddleware(AgentMiddleware): return None def before_agent(self, state: MemoryState, runtime: Runtime, config: RunnableConfig) -> MemoryStateUpdate | None: - """Load memory content before agent execution (synchronous). + """에이전트 실행 전에 메모리 콘텐츠를 로드합니다(동기). Loads memory from all configured sources and stores in state. Only loads if not already present in state. @@ -309,7 +281,7 @@ class MemoryMiddleware(AgentMiddleware): Returns: State update with memory_contents populated. """ - # Skip if already loaded + # 이미 로드되어 있으면 스킵 if "memory_contents" in state: return None @@ -325,7 +297,7 @@ class MemoryMiddleware(AgentMiddleware): return MemoryStateUpdate(memory_contents=contents) async def abefore_agent(self, state: MemoryState, runtime: Runtime, config: RunnableConfig) -> MemoryStateUpdate | None: - """Load memory content before agent execution. + """에이전트 실행 전에 메모리 콘텐츠를 로드합니다(async). Loads memory from all configured sources and stores in state. Only loads if not already present in state. @@ -338,7 +310,7 @@ class MemoryMiddleware(AgentMiddleware): Returns: State update with memory_contents populated. """ - # Skip if already loaded + # 이미 로드되어 있으면 스킵 if "memory_contents" in state: return None @@ -354,7 +326,7 @@ class MemoryMiddleware(AgentMiddleware): return MemoryStateUpdate(memory_contents=contents) def modify_request(self, request: ModelRequest) -> ModelRequest: - """Inject memory content into the system prompt. + """메모리 콘텐츠를 system prompt에 주입합니다. Args: request: Model request to modify. @@ -377,7 +349,7 @@ class MemoryMiddleware(AgentMiddleware): request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse], ) -> ModelResponse: - """Wrap model call to inject memory into system prompt. + """System prompt에 메모리를 주입한 뒤 model call을 수행하도록 감쌉니다. Args: request: Model request being processed. @@ -394,7 +366,7 @@ class MemoryMiddleware(AgentMiddleware): request: ModelRequest, handler: Callable[[ModelRequest], Awaitable[ModelResponse]], ) -> ModelResponse: - """Async wrap model call to inject memory into system prompt. + """(async) system prompt에 메모리를 주입한 뒤 model call을 수행하도록 감쌉니다. Args: request: Model request being processed. diff --git a/deepagents_sourcecode/libs/deepagents/deepagents/middleware/patch_tool_calls.py b/deepagents_sourcecode/libs/deepagents/deepagents/middleware/patch_tool_calls.py index f7a1d9b..5785f82 100644 --- a/deepagents_sourcecode/libs/deepagents/deepagents/middleware/patch_tool_calls.py +++ b/deepagents_sourcecode/libs/deepagents/deepagents/middleware/patch_tool_calls.py @@ -1,4 +1,4 @@ -"""Middleware to patch dangling tool calls in the messages history.""" +"""메시지 히스토리의 끊긴(tool message가 없는) tool call을 보정하는 미들웨어입니다.""" from typing import Any @@ -9,16 +9,16 @@ from langgraph.types import Overwrite class PatchToolCallsMiddleware(AgentMiddleware): - """Middleware to patch dangling tool calls in the messages history.""" + """메시지 히스토리의 끊긴(tool message가 없는) tool call을 보정합니다.""" def before_agent(self, state: AgentState, runtime: Runtime[Any]) -> dict[str, Any] | None: # noqa: ARG002 - """Before the agent runs, handle dangling tool calls from any AIMessage.""" + """에이전트 실행 전에, AIMessage에 남은 끊긴 tool call을 처리합니다.""" messages = state["messages"] if not messages or len(messages) == 0: return None patched_messages = [] - # Iterate over the messages and add any dangling tool calls + # 메시지를 순회하면서 끊긴 tool call이 있으면 ToolMessage를 보완합니다. for i, msg in enumerate(messages): patched_messages.append(msg) if msg.type == "ai" and msg.tool_calls: @@ -28,7 +28,7 @@ class PatchToolCallsMiddleware(AgentMiddleware): None, ) if corresponding_tool_msg is None: - # We have a dangling tool call which needs a ToolMessage + # ToolMessage가 누락된 끊긴 tool call이므로 보정합니다. tool_msg = ( f"Tool call {tool_call['name']} with id {tool_call['id']} was " "cancelled - another message came in before it could be completed." diff --git a/deepagents_sourcecode/libs/deepagents/deepagents/middleware/skills.py b/deepagents_sourcecode/libs/deepagents/deepagents/middleware/skills.py index b3592b5..5651302 100644 --- a/deepagents_sourcecode/libs/deepagents/deepagents/middleware/skills.py +++ b/deepagents_sourcecode/libs/deepagents/deepagents/middleware/skills.py @@ -1,34 +1,33 @@ -"""Skills middleware for loading and exposing agent skills to the system prompt. +"""에이전트 스킬(skills)을 로드하고 system prompt에 노출하는 미들웨어입니다. -This module implements Anthropic's agent skills pattern with progressive disclosure, -loading skills from backend storage via configurable sources. +이 모듈은 Anthropic의 agent skills 패턴(점진적 공개, progressive disclosure)을 구현하며, +backend 스토리지로부터 스킬을 로드하기 위해 “소스(sources)”를 설정할 수 있게 합니다. -## Architecture +## 아키텍처 -Skills are loaded from one or more **sources** - paths in a backend where skills are -organized. Sources are loaded in order, with later sources overriding earlier ones -when skills have the same name (last one wins). This enables layering: base -> user --> project -> team skills. +스킬은 backend 내에서 스킬들이 정리되어 있는 경로(prefix)인 **sources**로부터 로드됩니다. +sources는 지정된 순서대로 로드되며, 동일한 스킬 이름이 충돌할 경우 뒤에 오는 source가 우선합니다 +(last one wins). 이를 통해 `base -> user -> project -> team` 같은 레이어링이 가능합니다. -The middleware uses backend APIs exclusively (no direct filesystem access), making it -portable across different storage backends (filesystem, state, remote storage, etc.). +이 미들웨어는 backend API만 사용하며(직접 파일시스템 접근 없음), 따라서 filesystem/state/remote storage 등 +다양한 backend 구현에 이식(portable) 가능합니다. -For StateBackend (ephemeral/in-memory), use a factory function: +StateBackend(ephemeral/in-memory)를 사용할 때는 팩토리 함수를 전달하세요. ```python SkillsMiddleware(backend=lambda rt: StateBackend(rt), ...) ``` -## Skill Structure +## 스킬 디렉토리 구조 -Each skill is a directory containing a SKILL.md file with YAML frontmatter: +각 스킬은 YAML frontmatter가 포함된 `SKILL.md`를 가진 디렉토리입니다. ``` /skills/user/web-research/ -├── SKILL.md # Required: YAML frontmatter + markdown instructions -└── helper.py # Optional: supporting files +├── SKILL.md # 필수: YAML frontmatter + Markdown 지침 +└── helper.py # 선택: 보조 파일(스크립트/데이터 등) ``` -SKILL.md format: +`SKILL.md` 형식 예시: ```markdown --- name: web-research @@ -43,35 +42,34 @@ license: MIT ... ``` -## Skill Metadata (SkillMetadata) +## 스킬 메타데이터(SkillMetadata) -Parsed from YAML frontmatter per Agent Skills specification: -- `name`: Skill identifier (max 64 chars, lowercase alphanumeric and hyphens) -- `description`: What the skill does (max 1024 chars) -- `path`: Backend path to the SKILL.md file +YAML frontmatter에서 Agent Skills 사양에 따라 파싱되는 필드: +- `name`: 스킬 식별자(최대 64자, 소문자 영숫자+하이픈) +- `description`: 스킬 설명(최대 1024자) +- `path`: backend 내 `SKILL.md`의 경로 - Optional: `license`, `compatibility`, `metadata`, `allowed_tools` -## Sources +## 소스(Sources) -Sources are simply paths to skill directories in the backend. The source name is -derived from the last component of the path (e.g., "/skills/user/" -> "user"). +source는 backend 내 “스킬 디렉토리들의 루트 경로”입니다. +source의 표시 이름은 경로의 마지막 컴포넌트로부터 유도됩니다(예: `"/skills/user/" -> "user"`). -Example sources: ```python [ "/skills/user/", - "/skills/project/" + "/skills/project/", ] ``` -## Path Conventions +## 경로 규칙 -All paths use POSIX conventions (forward slashes) via `PurePosixPath`: -- Backend paths: "/skills/user/web-research/SKILL.md" -- Virtual, platform-independent -- Backends handle platform-specific conversions as needed +모든 경로는 `PurePosixPath`를 통해 POSIX 표기(슬래시 `/`)를 사용합니다. +- backend 경로 예: `"/skills/user/web-research/SKILL.md"` +- 플랫폼 독립적인 가상 경로(virtual path) +- 실제 플랫폼별 변환은 backend가 필요 시 처리합니다. -## Usage +## 사용 예시 ```python from deepagents.backends.state import StateBackend @@ -116,7 +114,7 @@ from langgraph.runtime import Runtime logger = logging.getLogger(__name__) -# Security: Maximum size for SKILL.md files to prevent DoS attacks (10MB) +# 보안: DoS 공격을 방지하기 위한 SKILL.md 최대 크기(10MB) MAX_SKILL_FILE_SIZE = 10 * 1024 * 1024 # Agent Skills specification constraints (https://agentskills.io/specification) @@ -125,46 +123,46 @@ MAX_SKILL_DESCRIPTION_LENGTH = 1024 class SkillMetadata(TypedDict): - """Metadata for a skill per Agent Skills specification (https://agentskills.io/specification).""" + """Agent Skills 사양(https://agentskills.io/specification)에 따른 스킬 메타데이터입니다.""" name: str - """Skill identifier (max 64 chars, lowercase alphanumeric and hyphens).""" + """스킬 식별자(최대 64자, 소문자 영숫자 및 하이픈).""" description: str - """What the skill does (max 1024 chars).""" + """스킬 설명(최대 1024자).""" path: str - """Path to the SKILL.md file.""" + """`SKILL.md` 파일의 경로.""" license: str | None - """License name or reference to bundled license file.""" + """라이선스 이름 또는 번들된 라이선스 파일에 대한 참조.""" compatibility: str | None - """Environment requirements (max 500 chars).""" + """환경 요구사항(최대 500자).""" metadata: dict[str, str] - """Arbitrary key-value mapping for additional metadata.""" + """추가 메타데이터를 위한 임의의 key-value 맵.""" allowed_tools: list[str] - """Space-delimited list of pre-approved tools. (Experimental)""" + """사전 승인된 도구 목록(공백 구분). (실험적)""" class SkillsState(AgentState): - """State for the skills middleware.""" + """SkillsMiddleware의 state 스키마입니다.""" skills_metadata: NotRequired[Annotated[list[SkillMetadata], PrivateStateAttr]] - """List of loaded skill metadata from all configured sources.""" + """설정된 모든 source에서 로드된 스킬 메타데이터 목록.""" class SkillsStateUpdate(TypedDict): - """State update for the skills middleware.""" + """SkillsMiddleware의 state 업데이트 타입입니다.""" skills_metadata: list[SkillMetadata] - """List of loaded skill metadata to merge into state.""" + """state에 병합할 스킬 메타데이터 목록.""" def _validate_skill_name(name: str, directory_name: str) -> tuple[bool, str]: - """Validate skill name per Agent Skills specification. + """Agent Skills 사양에 따라 스킬 이름을 검증합니다. Requirements per spec: - Max 64 characters @@ -197,7 +195,7 @@ def _parse_skill_metadata( skill_path: str, directory_name: str, ) -> SkillMetadata | None: - """Parse YAML frontmatter from SKILL.md content. + """SKILL.md에서 YAML frontmatter를 파싱합니다. Extracts metadata per Agent Skills specification from YAML frontmatter delimited by --- markers at the start of the content. @@ -280,7 +278,7 @@ def _parse_skill_metadata( def _list_skills(backend: BackendProtocol, source_path: str) -> list[SkillMetadata]: - """List all skills from a backend source. + """하나의 source(backend 경로)에서 모든 스킬을 나열합니다. Scans backend for subdirectories containing SKILL.md files, downloads their content, parses YAML frontmatter, and returns skill metadata. @@ -302,7 +300,7 @@ def _list_skills(backend: BackendProtocol, source_path: str) -> list[SkillMetada skills: list[SkillMetadata] = [] items = backend.ls_info(base_path) - # Find all skill directories (directories containing SKILL.md) + # 스킬 디렉토리 목록(SKILL.md를 담고 있을 수 있는 하위 디렉토리)을 수집 skill_dirs = [] for item in items: if not item.get("is_dir"): @@ -312,10 +310,10 @@ def _list_skills(backend: BackendProtocol, source_path: str) -> list[SkillMetada if not skill_dirs: return [] - # For each skill directory, check if SKILL.md exists and download it + # 각 스킬 디렉토리마다 SKILL.md 존재 여부를 확인하고 다운로드합니다. skill_md_paths = [] for skill_dir_path in skill_dirs: - # Construct SKILL.md path using PurePosixPath for safe, standardized path operations + # 안전하고 표준화된 경로 연산을 위해 PurePosixPath로 SKILL.md 경로를 구성합니다. skill_dir = PurePosixPath(skill_dir_path) skill_md_path = str(skill_dir / "SKILL.md") skill_md_paths.append((skill_dir_path, skill_md_path)) @@ -323,10 +321,10 @@ def _list_skills(backend: BackendProtocol, source_path: str) -> list[SkillMetada paths_to_download = [skill_md_path for _, skill_md_path in skill_md_paths] responses = backend.download_files(paths_to_download) - # Parse each downloaded SKILL.md + # 다운로드된 각 SKILL.md를 파싱합니다. for (skill_dir_path, skill_md_path), response in zip(skill_md_paths, responses, strict=True): if response.error: - # Skill doesn't have a SKILL.md, skip it + # SKILL.md가 없는 디렉토리는 스킵 continue if response.content is None: @@ -339,10 +337,10 @@ def _list_skills(backend: BackendProtocol, source_path: str) -> list[SkillMetada logger.warning("Error decoding %s: %s", skill_md_path, e) continue - # Extract directory name from path using PurePosixPath + # PurePosixPath로 디렉토리 이름을 추출합니다. directory_name = PurePosixPath(skill_dir_path).name - # Parse metadata + # 메타데이터 파싱 skill_metadata = _parse_skill_metadata( content=content, skill_path=skill_md_path, @@ -355,7 +353,7 @@ def _list_skills(backend: BackendProtocol, source_path: str) -> list[SkillMetada async def _alist_skills(backend: BackendProtocol, source_path: str) -> list[SkillMetadata]: - """List all skills from a backend source (async version). + """하나의 source(backend 경로)에서 모든 스킬을 나열합니다(async 버전). Scans backend for subdirectories containing SKILL.md files, downloads their content, parses YAML frontmatter, and returns skill metadata. @@ -377,7 +375,7 @@ async def _alist_skills(backend: BackendProtocol, source_path: str) -> list[Skil skills: list[SkillMetadata] = [] items = await backend.als_info(base_path) - # Find all skill directories (directories containing SKILL.md) + # 스킬 디렉토리 목록(SKILL.md를 담고 있을 수 있는 하위 디렉토리)을 수집 skill_dirs = [] for item in items: if not item.get("is_dir"): @@ -387,10 +385,10 @@ async def _alist_skills(backend: BackendProtocol, source_path: str) -> list[Skil if not skill_dirs: return [] - # For each skill directory, check if SKILL.md exists and download it + # 각 스킬 디렉토리마다 SKILL.md 존재 여부를 확인하고 다운로드합니다. skill_md_paths = [] for skill_dir_path in skill_dirs: - # Construct SKILL.md path using PurePosixPath for safe, standardized path operations + # 안전하고 표준화된 경로 연산을 위해 PurePosixPath로 SKILL.md 경로를 구성합니다. skill_dir = PurePosixPath(skill_dir_path) skill_md_path = str(skill_dir / "SKILL.md") skill_md_paths.append((skill_dir_path, skill_md_path)) @@ -398,10 +396,10 @@ async def _alist_skills(backend: BackendProtocol, source_path: str) -> list[Skil paths_to_download = [skill_md_path for _, skill_md_path in skill_md_paths] responses = await backend.adownload_files(paths_to_download) - # Parse each downloaded SKILL.md + # 다운로드된 각 SKILL.md를 파싱합니다. for (skill_dir_path, skill_md_path), response in zip(skill_md_paths, responses, strict=True): if response.error: - # Skill doesn't have a SKILL.md, skip it + # SKILL.md가 없는 디렉토리는 스킵 continue if response.content is None: @@ -414,10 +412,10 @@ async def _alist_skills(backend: BackendProtocol, source_path: str) -> list[Skil logger.warning("Error decoding %s: %s", skill_md_path, e) continue - # Extract directory name from path using PurePosixPath + # PurePosixPath로 디렉토리 이름을 추출합니다. directory_name = PurePosixPath(skill_dir_path).name - # Parse metadata + # 메타데이터 파싱 skill_metadata = _parse_skill_metadata( content=content, skill_path=skill_md_path, @@ -472,7 +470,7 @@ Remember: Skills make you more capable and consistent. When in doubt, check if a class SkillsMiddleware(AgentMiddleware): - """Middleware for loading and exposing agent skills to the system prompt. + """에이전트 스킬을 로드하고 system prompt에 노출하는 미들웨어입니다. Loads skills from backend sources and injects them into the system prompt using progressive disclosure (metadata first, full content on demand). @@ -501,7 +499,7 @@ class SkillsMiddleware(AgentMiddleware): state_schema = SkillsState def __init__(self, *, backend: BACKEND_TYPES, sources: list[str]) -> None: - """Initialize the skills middleware. + """스킬 미들웨어를 초기화합니다. Args: backend: Backend instance or factory function that takes runtime and returns a backend. @@ -513,7 +511,7 @@ class SkillsMiddleware(AgentMiddleware): self.system_prompt_template = SKILLS_SYSTEM_PROMPT def _get_backend(self, state: SkillsState, runtime: Runtime, config: RunnableConfig) -> BackendProtocol: - """Resolve backend from instance or factory. + """백엔드 인스턴스/팩토리로부터 실제 백엔드를 해석(resolve)합니다. Args: state: Current agent state. @@ -524,7 +522,7 @@ class SkillsMiddleware(AgentMiddleware): Resolved backend instance """ if callable(self._backend): - # Construct an artificial tool runtime to resolve backend factory + # backend 팩토리를 호출하기 위한 ToolRuntime을 구성합니다. tool_runtime = ToolRuntime( state=state, context=runtime.context, @@ -541,7 +539,7 @@ class SkillsMiddleware(AgentMiddleware): return self._backend def _format_skills_locations(self) -> str: - """Format skills locations for display in system prompt.""" + """System prompt에 표시할 skills location 섹션을 포맷팅합니다.""" locations = [] for i, source_path in enumerate(self.sources): name = PurePosixPath(source_path.rstrip("/")).name.capitalize() @@ -550,7 +548,7 @@ class SkillsMiddleware(AgentMiddleware): return "\n".join(locations) def _format_skills_list(self, skills: list[SkillMetadata]) -> str: - """Format skills metadata for display in system prompt.""" + """System prompt에 표시할 skills 목록을 포맷팅합니다.""" if not skills: paths = [f"{source_path}" for source_path in self.sources] return f"(No skills available yet. You can create skills in {' or '.join(paths)})" @@ -563,7 +561,7 @@ class SkillsMiddleware(AgentMiddleware): return "\n".join(lines) def modify_request(self, request: ModelRequest) -> ModelRequest: - """Inject skills documentation into a model request's system prompt. + """모델 요청의 system prompt에 skills 섹션을 주입합니다. Args: request: Model request to modify @@ -588,7 +586,7 @@ class SkillsMiddleware(AgentMiddleware): return request.override(system_prompt=system_prompt) def before_agent(self, state: SkillsState, runtime: Runtime, config: RunnableConfig) -> SkillsStateUpdate | None: - """Load skills metadata before agent execution (synchronous). + """에이전트 실행 전에 스킬 메타데이터를 로드합니다(동기). Runs before each agent interaction to discover available skills from all configured sources. Re-loads on every call to capture any changes. @@ -604,16 +602,16 @@ class SkillsMiddleware(AgentMiddleware): Returns: State update with skills_metadata populated, or None if already present """ - # Skip if skills_metadata is already present in state (even if empty) + # state에 skills_metadata가 이미 있으면(비어 있어도) 스킵 if "skills_metadata" in state: return None - # Resolve backend (supports both direct instances and factory functions) + # backend 해석(인스턴스/팩토리 모두 지원) backend = self._get_backend(state, runtime, config) all_skills: dict[str, SkillMetadata] = {} - # Load skills from each source in order - # Later sources override earlier ones (last one wins) + # source를 순서대로 로드합니다. + # 뒤에 오는 source가 앞의 source를 덮어씁니다(last one wins). for source_path in self.sources: source_skills = _list_skills(backend, source_path) for skill in source_skills: @@ -623,7 +621,7 @@ class SkillsMiddleware(AgentMiddleware): return SkillsStateUpdate(skills_metadata=skills) async def abefore_agent(self, state: SkillsState, runtime: Runtime, config: RunnableConfig) -> SkillsStateUpdate | None: - """Load skills metadata before agent execution (async). + """에이전트 실행 전에 스킬 메타데이터를 로드합니다(async). Runs before each agent interaction to discover available skills from all configured sources. Re-loads on every call to capture any changes. @@ -639,16 +637,16 @@ class SkillsMiddleware(AgentMiddleware): Returns: State update with skills_metadata populated, or None if already present """ - # Skip if skills_metadata is already present in state (even if empty) + # state에 skills_metadata가 이미 있으면(비어 있어도) 스킵 if "skills_metadata" in state: return None - # Resolve backend (supports both direct instances and factory functions) + # backend 해석(인스턴스/팩토리 모두 지원) backend = self._get_backend(state, runtime, config) all_skills: dict[str, SkillMetadata] = {} - # Load skills from each source in order - # Later sources override earlier ones (last one wins) + # source를 순서대로 로드합니다. + # 뒤에 오는 source가 앞의 source를 덮어씁니다(last one wins). for source_path in self.sources: source_skills = await _alist_skills(backend, source_path) for skill in source_skills: @@ -662,7 +660,7 @@ class SkillsMiddleware(AgentMiddleware): request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse], ) -> ModelResponse: - """Inject skills documentation into the system prompt. + """System prompt에 skills 섹션을 주입한 뒤 model call을 수행하도록 감쌉니다. Args: request: Model request being processed @@ -679,7 +677,7 @@ class SkillsMiddleware(AgentMiddleware): request: ModelRequest, handler: Callable[[ModelRequest], Awaitable[ModelResponse]], ) -> ModelResponse: - """Inject skills documentation into the system prompt (async version). + """(async) System prompt에 skills 섹션을 주입한 뒤 model call을 수행하도록 감쌉니다. Args: request: Model request being processed diff --git a/deepagents_sourcecode/libs/deepagents/deepagents/middleware/subagents.py b/deepagents_sourcecode/libs/deepagents/deepagents/middleware/subagents.py index 5adb121..0174ab4 100644 --- a/deepagents_sourcecode/libs/deepagents/deepagents/middleware/subagents.py +++ b/deepagents_sourcecode/libs/deepagents/deepagents/middleware/subagents.py @@ -1,4 +1,4 @@ -"""Middleware for providing subagents to an agent via a `task` tool.""" +"""`task` 도구를 통해 서브에이전트를 제공하는 미들웨어입니다.""" from collections.abc import Awaitable, Callable, Sequence from typing import Any, NotRequired, TypedDict, cast @@ -15,57 +15,54 @@ from langgraph.types import Command class SubAgent(TypedDict): - """Specification for an agent. + """서브에이전트 명세(spec)입니다. - When specifying custom agents, the `default_middleware` from `SubAgentMiddleware` - will be applied first, followed by any `middleware` specified in this spec. - To use only custom middleware without the defaults, pass `default_middleware=[]` - to `SubAgentMiddleware`. + 커스텀 서브에이전트를 지정할 때, `SubAgentMiddleware`의 `default_middleware`가 먼저 적용되고, + 그 다음 이 spec의 `middleware`가 뒤에 추가됩니다. + 기본 미들웨어 없이 커스텀 미들웨어만 사용하려면 `SubAgentMiddleware(default_middleware=[])`로 설정하세요. """ name: str - """The name of the agent.""" + """에이전트 이름.""" description: str - """The description of the agent.""" + """에이전트 설명.""" system_prompt: str - """The system prompt to use for the agent.""" + """서브에이전트에 사용할 system prompt.""" tools: Sequence[BaseTool | Callable | dict[str, Any]] - """The tools to use for the agent.""" + """서브에이전트에 제공할 도구 목록.""" model: NotRequired[str | BaseChatModel] - """The model for the agent. Defaults to `default_model`.""" + """서브에이전트 모델(기본값: `default_model`).""" middleware: NotRequired[list[AgentMiddleware]] - """Additional middleware to append after `default_middleware`.""" + """`default_middleware` 뒤에 추가로 붙일 미들웨어.""" interrupt_on: NotRequired[dict[str, bool | InterruptOnConfig]] - """The tool configs to use for the agent.""" + """서브에이전트에 적용할 tool interrupt 설정.""" class CompiledSubAgent(TypedDict): - """A pre-compiled agent spec.""" + """사전 컴파일된(pre-compiled) 서브에이전트 명세입니다.""" name: str - """The name of the agent.""" + """에이전트 이름.""" description: str - """The description of the agent.""" + """에이전트 설명.""" runnable: Runnable - """The Runnable to use for the agent.""" + """서브에이전트 실행에 사용할 `Runnable`.""" DEFAULT_SUBAGENT_PROMPT = "In order to complete the objective that the user asks of you, you have access to a number of standard tools." -# State keys that are excluded when passing state to subagents and when returning -# updates from subagents. -# When returning updates: -# 1. The messages key is handled explicitly to ensure only the final message is included -# 2. The todos and structured_response keys are excluded as they do not have a defined reducer -# and no clear meaning for returning them from a subagent to the main agent. +# 서브에이전트 호출 시 전달하지 않거나, 서브에이전트 결과를 메인으로 되돌릴 때 제외하는 state 키들입니다. +# 반환 업데이트 처리 시: +# 1) `messages`는 최종 메시지만 포함되도록 별도로 처리합니다. +# 2) `todos`, `structured_response`는 reducer가 정의되어 있지 않고 메인 에이전트로 반환할 의미가 불명확하므로 제외합니다. _EXCLUDED_STATE_KEYS = {"messages", "todos", "structured_response"} TASK_TOOL_DESCRIPTION = """Launch an ephemeral subagent to handle complex, multi-step independent tasks with isolated context windows. @@ -219,29 +216,29 @@ def _get_subagents( subagents: list[SubAgent | CompiledSubAgent], general_purpose_agent: bool, ) -> tuple[dict[str, Any], list[str]]: - """Create subagent instances from specifications. + """서브에이전트 spec에서 runnable 인스턴스를 생성합니다. Args: - default_model: Default model for subagents that don't specify one. - default_tools: Default tools for subagents that don't specify tools. - default_middleware: Middleware to apply to all subagents. If `None`, - no default middleware is applied. - default_interrupt_on: The tool configs to use for the default general-purpose subagent. These - are also the fallback for any subagents that don't specify their own tool configs. - subagents: List of agent specifications or pre-compiled agents. - general_purpose_agent: Whether to include a general-purpose subagent. + default_model: 서브에이전트가 별도 모델을 지정하지 않을 때 사용할 기본 모델. + default_tools: 서브에이전트가 별도 도구를 지정하지 않을 때 사용할 기본 도구. + default_middleware: 모든 서브에이전트에 공통 적용할 미들웨어. `None`이면 적용하지 않습니다. + default_interrupt_on: 기본 general-purpose 서브에이전트에 사용할 tool interrupt 설정. + 또한 개별 서브에이전트가 별도 설정을 지정하지 않았을 때의 fallback입니다. + subagents: 서브에이전트 spec 또는 사전 컴파일된 agent 목록. + general_purpose_agent: general-purpose 서브에이전트를 포함할지 여부. Returns: - Tuple of (agent_dict, description_list) where agent_dict maps agent names - to runnable instances and description_list contains formatted descriptions. + `(agent_dict, description_list)` 튜플. + `agent_dict`는 에이전트 이름 → runnable 인스턴스를 매핑하고, + `description_list`는 task 도구에 주입될 포맷된 설명 목록입니다. """ - # Use empty list if None (no default middleware) + # `None`이면 빈 리스트로 대체(기본 미들웨어 미적용) default_subagent_middleware = default_middleware or [] agents: dict[str, Any] = {} subagent_descriptions = [] - # Create general-purpose agent if enabled + # general-purpose 에이전트(선택)를 생성 if general_purpose_agent: general_purpose_middleware = [*default_subagent_middleware] if default_interrupt_on: @@ -255,7 +252,7 @@ def _get_subagents( agents["general-purpose"] = general_purpose_subagent subagent_descriptions.append(f"- general-purpose: {DEFAULT_GENERAL_PURPOSE_DESCRIPTION}") - # Process custom subagents + # 커스텀 서브에이전트를 처리 for agent_ in subagents: subagent_descriptions.append(f"- {agent_['name']}: {agent_['description']}") if "runnable" in agent_: @@ -291,7 +288,7 @@ def _create_task_tool( general_purpose_agent: bool, task_description: str | None = None, ) -> BaseTool: - """Create a task tool for invoking subagents. + """서브에이전트를 호출하는 `task` 도구를 생성합니다. Args: default_model: Default model for subagents. @@ -319,7 +316,7 @@ def _create_task_tool( def _return_command_with_state_update(result: dict, tool_call_id: str) -> Command: state_update = {k: v for k, v in result.items() if k not in _EXCLUDED_STATE_KEYS} - # Strip trailing whitespace to prevent API errors with Anthropic + # Anthropic API에서 오류가 나지 않도록 trailing whitespace를 제거합니다. message_text = result["messages"][-1].text.rstrip() if result["messages"][-1].text else "" return Command( update={ @@ -329,14 +326,14 @@ def _create_task_tool( ) def _validate_and_prepare_state(subagent_type: str, description: str, runtime: ToolRuntime) -> tuple[Runnable, dict]: - """Prepare state for invocation.""" + """서브에이전트 호출을 위한 state를 준비합니다.""" subagent = subagent_graphs[subagent_type] # Create a new state dict to avoid mutating the original subagent_state = {k: v for k, v in runtime.state.items() if k not in _EXCLUDED_STATE_KEYS} subagent_state["messages"] = [HumanMessage(content=description)] return subagent, subagent_state - # Use custom description if provided, otherwise use default template + # 커스텀 설명이 주어지면 사용하고, 아니면 기본 템플릿을 사용합니다. if task_description is None: task_description = TASK_TOOL_DESCRIPTION.format(available_agents=subagent_description_str) elif "{available_agents}" in task_description: @@ -382,7 +379,7 @@ def _create_task_tool( class SubAgentMiddleware(AgentMiddleware): - """Middleware for providing subagents to an agent via a `task` tool. + """`task` 도구를 통해 에이전트에 서브에이전트를 제공하는 미들웨어입니다. This middleware adds a `task` tool to the agent that can be used to invoke subagents. Subagents are useful for handling complex tasks that require multiple steps, or tasks @@ -454,7 +451,7 @@ class SubAgentMiddleware(AgentMiddleware): general_purpose_agent: bool = True, task_description: str | None = None, ) -> None: - """Initialize the SubAgentMiddleware.""" + """SubAgentMiddleware를 초기화합니다.""" super().__init__() self.system_prompt = system_prompt task_tool = _create_task_tool( @@ -473,7 +470,7 @@ class SubAgentMiddleware(AgentMiddleware): request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse], ) -> ModelResponse: - """Update the system prompt to include instructions on using subagents.""" + """System prompt에 서브에이전트 사용 지침을 포함하도록 업데이트합니다.""" if self.system_prompt is not None: system_prompt = request.system_prompt + "\n\n" + self.system_prompt if request.system_prompt else self.system_prompt return handler(request.override(system_prompt=system_prompt)) @@ -484,7 +481,7 @@ class SubAgentMiddleware(AgentMiddleware): request: ModelRequest, handler: Callable[[ModelRequest], Awaitable[ModelResponse]], ) -> ModelResponse: - """(async) Update the system prompt to include instructions on using subagents.""" + """(async) System prompt에 서브에이전트 사용 지침을 포함하도록 업데이트합니다.""" if self.system_prompt is not None: system_prompt = request.system_prompt + "\n\n" + self.system_prompt if request.system_prompt else self.system_prompt return await handler(request.override(system_prompt=system_prompt)) diff --git a/deepagents_sourcecode/libs/harbor/deepagents_harbor/backend.py b/deepagents_sourcecode/libs/harbor/deepagents_harbor/backend.py index c21f949..d96336a 100644 --- a/deepagents_sourcecode/libs/harbor/deepagents_harbor/backend.py +++ b/deepagents_sourcecode/libs/harbor/deepagents_harbor/backend.py @@ -1,4 +1,4 @@ -"""Implement harbor backend.""" +"""Harbor backend 구현입니다.""" import base64 import shlex @@ -15,22 +15,21 @@ from harbor.environments.base import BaseEnvironment class HarborSandbox(SandboxBackendProtocol): - """A sandbox implementation without assuming that python3 is available.""" + """python3 존재를 가정하지 않는 샌드박스 구현체입니다.""" def __init__(self, environment: BaseEnvironment) -> None: - """Initialize HarborSandbox with the given environment.""" + """주어진 environment로 HarborSandbox를 초기화합니다.""" self.environment = environment async def aexecute( self, command: str, ) -> ExecuteResponse: - """Execute a bash command in the task environment.""" + """작업 환경에서 bash 커맨드를 실행합니다(async).""" result = await self.environment.exec(command) - # These errors appear in harbor environments when running bash commands - # in non-interactive/non-TTY contexts. They're harmless artifacts. - # Filter them from both stdout and stderr, then collect them to show in stderr. + # Harbor 환경에서 non-interactive/non-TTY 컨텍스트로 bash를 실행할 때 자주 등장하는 오류 메시지들입니다. + # 대부분 무해한 아티팩트이므로 stdout/stderr에서 제거한 뒤, stderr에만 정리해서 붙입니다. error_messages = [ "bash: cannot set terminal process group (-1): Inappropriate ioctl for device", "bash: no job control in this shell", @@ -40,7 +39,7 @@ class HarborSandbox(SandboxBackendProtocol): stdout = result.stdout or "" stderr = result.stderr or "" - # Collect the bash messages if they appear (to move to stderr) + # bash 메시지가 있으면 수집하여(stderr로 이동) bash_messages = [] for error_msg in error_messages: if error_msg in stdout: @@ -52,12 +51,12 @@ class HarborSandbox(SandboxBackendProtocol): stdout = stdout.strip() stderr = stderr.strip() - # Add bash messages to stderr + # bash 메시지를 stderr에 추가 if bash_messages: bash_msg_text = "\n".join(bash_messages) stderr = f"{bash_msg_text}\n{stderr}".strip() if stderr else bash_msg_text - # Only append stderr label if there's actual stderr content + # stderr가 실제로 있을 때만 라벨을 붙입니다. if stderr: output = stdout + "\n\n stderr: " + stderr if stdout else "\n stderr: " + stderr else: @@ -71,12 +70,12 @@ class HarborSandbox(SandboxBackendProtocol): self, command: str, ) -> ExecuteResponse: - """Execute a bash command in the task environment.""" + """작업 환경에서 bash 커맨드를 실행합니다.""" raise NotImplementedError("This backend only supports async execution") @property def id(self) -> str: - """Unique identifier for the sandbox backend.""" + """샌드박스 백엔드 인스턴스의 고유 식별자.""" return self.environment.session_id async def aread( @@ -85,11 +84,11 @@ class HarborSandbox(SandboxBackendProtocol): offset: int = 0, limit: int = 2000, ) -> str: - """Read file content with line numbers using shell commands.""" - # Escape file path for shell + """셸 커맨드를 이용해 파일을 읽고 라인 번호와 함께 반환합니다(async).""" + # 셸에서 안전하게 쓰도록 경로를 escape safe_path = shlex.quote(file_path) - # Check if file exists and handle empty files + # 파일 존재 여부 확인 및 빈 파일 처리 cmd = f""" if [ ! -f {safe_path} ]; then echo "Error: File not found" diff --git a/deepagents_sourcecode/libs/harbor/deepagents_harbor/deepagents_wrapper.py b/deepagents_sourcecode/libs/harbor/deepagents_harbor/deepagents_wrapper.py index 9fb215d..3d938bc 100644 --- a/deepagents_sourcecode/libs/harbor/deepagents_harbor/deepagents_wrapper.py +++ b/deepagents_sourcecode/libs/harbor/deepagents_harbor/deepagents_wrapper.py @@ -1,4 +1,4 @@ -"""A wrapper for DeepAgents to run in Harbor environments.""" +"""Harbor 환경에서 DeepAgents를 실행하기 위한 래퍼(wrapper)입니다.""" import json import os @@ -7,14 +7,11 @@ from datetime import datetime, timezone from pathlib import Path from deepagents import create_deep_agent +from deepagents_cli.agent import create_cli_agent from dotenv import load_dotenv from harbor.agents.base import BaseAgent from harbor.environments.base import BaseEnvironment from harbor.models.agent.context import AgentContext - -# Load .env file if present -load_dotenv() -from deepagents_cli.agent import create_cli_agent from harbor.models.trajectories import ( Agent, FinalMetrics, @@ -33,6 +30,9 @@ from langsmith import trace from deepagents_harbor.backend import HarborSandbox from deepagents_harbor.tracing import create_example_id_from_instruction +# .env 파일이 있으면 로드합니다. +load_dotenv() + SYSTEM_MESSAGE = """ You are an autonomous agent executing tasks in a sandboxed environment. Follow these instructions carefully. @@ -53,9 +53,9 @@ Your current working directory is: class DeepAgentsWrapper(BaseAgent): - """Harbor agent implementation using LangChain DeepAgents. + """LangChain DeepAgents를 이용한 Harbor 에이전트 구현체입니다. - Wraps DeepAgents to execute tasks in Harbor environments. + Harbor 환경에서 DeepAgents로 작업을 실행할 수 있도록 감쌉니다. """ def __init__( @@ -68,7 +68,7 @@ class DeepAgentsWrapper(BaseAgent): *args, **kwargs, ) -> None: - """Initialize DeepAgentsWrapper. + """DeepAgentsWrapper를 초기화합니다. Args: logs_dir: Directory for storing logs @@ -99,7 +99,7 @@ class DeepAgentsWrapper(BaseAgent): return "deepagent-harbor" async def setup(self, environment: BaseEnvironment) -> None: - """Setup the agent with the given environment. + """주어진 environment로 에이전트를 설정합니다. Args: environment: Harbor environment (Docker, Modal, etc.) @@ -107,11 +107,11 @@ class DeepAgentsWrapper(BaseAgent): pass def version(self) -> str | None: - """The version of the agent.""" + """에이전트 버전을 반환합니다.""" return "0.0.1" async def _get_formatted_system_prompt(self, backend: HarborSandbox) -> str: - """Format the system prompt with current directory and file listing context. + """현재 디렉토리/파일 목록 컨텍스트를 포함하도록 system prompt를 포맷팅합니다. Args: backend: Harbor sandbox backend to query for directory information @@ -126,7 +126,6 @@ class DeepAgentsWrapper(BaseAgent): # Get first 10 files total_files = len(ls_info) if ls_info else 0 first_10_files = ls_info[:10] if ls_info else [] - has_more = total_files > 10 # Build file listing header based on actual count if total_files == 0: @@ -157,7 +156,7 @@ class DeepAgentsWrapper(BaseAgent): environment: BaseEnvironment, context: AgentContext, ) -> None: - """Execute the DeepAgent on the given instruction. + """주어진 instruction으로 DeepAgent를 실행합니다. Args: instruction: The task to complete @@ -259,7 +258,7 @@ class DeepAgentsWrapper(BaseAgent): def _save_trajectory( self, environment: BaseEnvironment, instruction: str, result: dict ) -> None: - """Save current trajectory to logs directory.""" + """현재 trajectory를 logs 디렉토리에 저장합니다.""" # Track token usage and cost for this run total_prompt_tokens = 0 total_completion_tokens = 0 diff --git a/deepagents_sourcecode/libs/harbor/deepagents_harbor/tracing.py b/deepagents_sourcecode/libs/harbor/deepagents_harbor/tracing.py index 8657d2d..8c62abb 100644 --- a/deepagents_sourcecode/libs/harbor/deepagents_harbor/tracing.py +++ b/deepagents_sourcecode/libs/harbor/deepagents_harbor/tracing.py @@ -1,11 +1,11 @@ -"""LangSmith integration for Harbor DeepAgents.""" +"""Harbor DeepAgents용 LangSmith 연동 코드입니다.""" import hashlib import uuid def create_example_id_from_instruction(instruction: str, seed: int = 42) -> str: - """Create a deterministic UUID from an instruction string. + """instruction 문자열로부터 결정적인(deterministic) UUID를 생성합니다. Normalizes the instruction by stripping whitespace and creating a SHA-256 hash, then converting to a UUID for LangSmith compatibility. @@ -17,16 +17,16 @@ def create_example_id_from_instruction(instruction: str, seed: int = 42) -> str: Returns: A UUID string generated from the hash of the normalized instruction """ - # Normalize the instruction: strip leading/trailing whitespace + # instruction 정규화: 앞/뒤 공백 제거 normalized = instruction.strip() - # Prepend seed as bytes to the instruction for hashing + # 해싱을 위해 seed를 bytes로 변환해 instruction 앞에 붙입니다. seeded_data = seed.to_bytes(8, byteorder="big") + normalized.encode("utf-8") - # Create SHA-256 hash of the seeded instruction + # seed가 포함된 instruction의 SHA-256 해시 생성 hash_bytes = hashlib.sha256(seeded_data).digest() - # Use first 16 bytes to create a UUID + # 앞 16바이트를 사용해 UUID 생성 example_uuid = uuid.UUID(bytes=hash_bytes[:16]) return str(example_uuid) diff --git a/deepagents_sourcecode/libs/harbor/scripts/analyze.py b/deepagents_sourcecode/libs/harbor/scripts/analyze.py index bac8a4a..c333b8e 100755 --- a/deepagents_sourcecode/libs/harbor/scripts/analyze.py +++ b/deepagents_sourcecode/libs/harbor/scripts/analyze.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 -"""Analyze job trials from a jobs directory. +"""jobs 디렉토리의 trial 실행 결과를 분석합니다. + +Analyze job trials from a jobs directory. Scans through trial directories, extracts trajectory data and success metrics. """ @@ -783,7 +785,7 @@ async def main(): if output_file: print(f" ✓ Analysis written to: {output_file}") else: - print(f" ✗ Skipped (no trajectory or already completed)") + print(" ✗ Skipped (no trajectory or already completed)") except Exception as e: print(f" ✗ Error: {e}") diff --git a/deepagents_sourcecode/libs/harbor/scripts/harbor_langsmith.py b/deepagents_sourcecode/libs/harbor/scripts/harbor_langsmith.py index 915f4ee..e2eb82a 100755 --- a/deepagents_sourcecode/libs/harbor/scripts/harbor_langsmith.py +++ b/deepagents_sourcecode/libs/harbor/scripts/harbor_langsmith.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 -""" +"""Harbor용 LangSmith 연동 CLI입니다. + CLI for LangSmith integration with Harbor. Provides commands for: @@ -243,12 +244,12 @@ async def create_experiment_async(dataset_name: str, experiment_name: str | None session_id = experiment_session["id"] tenant_id = experiment_session["tenant_id"] - print(f"✓ Experiment created successfully!") + print("✓ Experiment created successfully!") print(f" Session ID: {session_id}") print( f" View at: https://smith.langchain.com/o/{tenant_id}/datasets/{dataset_id}/compare?selectedSessions={session_id}" ) - print(f"\nTo run Harbor with this experiment, use:") + print("\nTo run Harbor with this experiment, use:") print(f" LANGSMITH_EXPERIMENT={experiment_name} harbor run ...") return session_id diff --git a/pyproject.toml b/pyproject.toml index d16a6c6..5aa7a90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,10 @@ packages = ["research_agent"] "*" = ["py.typed"] [tool.ruff] -lint.select = [ +extend-exclude = ["*.ipynb"] + +[tool.ruff.lint] +select = [ "E", # pycodestyle "F", # pyflakes "I", # isort @@ -50,14 +53,15 @@ lint.select = [ "T201", "UP", ] -lint.ignore = ["UP006", "UP007", "UP035", "D417", "E501", "D101", "D102", "D103", "D105", "D107"] - -[tool.ruff.lint.per-file-ignores] -"tests/*" = ["D", "UP", "T201"] +ignore = ["UP006", "UP007", "UP035", "D417", "E501", "D101", "D102", "D103", "D105", "D107"] [tool.ruff.lint.pydocstyle] convention = "google" +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["D", "UP", "T201"] +"skills/**/*.py" = ["T201"] + [tool.pytest.ini_options] markers = [ "integration: 실제 외부 서비스(Docker, API 등)를 사용하는 통합 테스트",