mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(agent-world): add job-apply agent tool + apply success UX (#3848)
This commit is contained in:
@@ -472,6 +472,105 @@ describe('Jobs write actions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('Apply success shows success state and triggers refetch', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(fetchWalletStatus).mockResolvedValue(sampleWalletStatus as any);
|
||||
// First call: initial load; second call should happen after successful apply.
|
||||
vi.mocked(apiClient.graphql.jobs).mockResolvedValue({ jobs: [sampleJob], count: 1 });
|
||||
vi.mocked(apiClient.jobsWrite.apply).mockResolvedValue(sampleProposal as any);
|
||||
|
||||
render(<JobsSection />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Build a dashboard widget')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText('Build a dashboard widget'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /^apply$/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /^apply$/i }));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /submit application/i }));
|
||||
|
||||
// After success, the jobs list should be refetched (called a second time).
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(apiClient.graphql.jobs)).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
test('Apply form shows error on failure', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(fetchWalletStatus).mockResolvedValue(sampleWalletStatus as any);
|
||||
vi.mocked(apiClient.graphql.jobs).mockResolvedValue({ jobs: [sampleJob], count: 1 });
|
||||
vi.mocked(apiClient.jobsWrite.apply).mockRejectedValue(new Error('Network error'));
|
||||
|
||||
render(<JobsSection />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Build a dashboard widget')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText('Build a dashboard widget'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /^apply$/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /^apply$/i }));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /submit application/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
// The error message should appear in the modal.
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(/network error/i);
|
||||
});
|
||||
|
||||
// Modal stays open so user can retry.
|
||||
expect(screen.getByRole('button', { name: /submit application/i })).toBeInTheDocument();
|
||||
// Refetch should NOT have been called on failure.
|
||||
expect(vi.mocked(apiClient.graphql.jobs)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('Apply button shows loading state while submitting', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(fetchWalletStatus).mockResolvedValue(sampleWalletStatus as any);
|
||||
vi.mocked(apiClient.graphql.jobs).mockResolvedValue({ jobs: [sampleJob], count: 1 });
|
||||
|
||||
let resolveApply: (v: unknown) => void;
|
||||
vi.mocked(apiClient.jobsWrite.apply).mockReturnValue(
|
||||
new Promise(resolve => {
|
||||
resolveApply = resolve;
|
||||
}) as any
|
||||
);
|
||||
|
||||
render(<JobsSection />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Build a dashboard widget')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText('Build a dashboard widget'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /^apply$/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /^apply$/i }));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /submit application/i }));
|
||||
|
||||
// While the request is in-flight, the button shows "Applying..." and is disabled.
|
||||
await waitFor(() => {
|
||||
const btn = screen.getByRole('button', { name: /applying/i });
|
||||
expect(btn).toBeInTheDocument();
|
||||
expect(btn).toBeDisabled();
|
||||
});
|
||||
|
||||
// Clean up the pending promise.
|
||||
resolveApply!(sampleProposal);
|
||||
});
|
||||
|
||||
test('Own job shows Cancel Job and View Proposals buttons', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(fetchWalletStatus).mockResolvedValue({
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* Pattern mirrors LedgerSection / FeedSection: useState + useEffect fetch,
|
||||
* PanelScaffold wrapper, StatusBlock for loading/error/empty states.
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import PanelScaffold from '../../components/layout/PanelScaffold';
|
||||
import Button from '../../components/ui/Button';
|
||||
@@ -368,11 +368,23 @@ function ApplyModal({
|
||||
onClose: () => void;
|
||||
onApplied: () => void;
|
||||
}) {
|
||||
const { t } = useT();
|
||||
const [coverLetter, setCoverLetter] = useState('');
|
||||
const [bidAmount, setBidAmount] = useState('');
|
||||
const [estimatedDelivery, setEstimatedDelivery] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [succeeded, setSucceeded] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const successTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Clear the auto-close timer on unmount to avoid state updates after teardown.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (successTimerRef.current) {
|
||||
clearTimeout(successTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -385,8 +397,12 @@ function ApplyModal({
|
||||
};
|
||||
try {
|
||||
await apiClient.jobsWrite.apply(jobId, params);
|
||||
setSucceeded(true);
|
||||
onApplied();
|
||||
onClose();
|
||||
// Auto-close after a short success display window.
|
||||
successTimerRef.current = setTimeout(() => {
|
||||
onClose();
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
} finally {
|
||||
@@ -397,60 +413,80 @@ function ApplyModal({
|
||||
return (
|
||||
<ModalShell
|
||||
onClose={onClose}
|
||||
title="Apply for Job"
|
||||
title={t('agentworld.jobs.applyModal.title')}
|
||||
titleId="apply-modal-title"
|
||||
maxWidthClassName="max-w-lg">
|
||||
<form
|
||||
onSubmit={e => {
|
||||
void handleSubmit(e);
|
||||
}}
|
||||
className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-stone-700 dark:text-neutral-300">
|
||||
Cover Letter
|
||||
</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={coverLetter}
|
||||
onChange={e => setCoverLetter(e.target.value)}
|
||||
className="w-full rounded border border-stone-300 bg-white px-2.5 py-1.5 text-sm text-stone-900 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
|
||||
placeholder="Describe your experience and why you're a good fit"
|
||||
/>
|
||||
{succeeded ? (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="flex flex-col items-center gap-3 py-6 text-center">
|
||||
<p className="text-sm font-medium text-green-700 dark:text-green-400">
|
||||
{t('agentworld.jobs.applyModal.successHeading')}
|
||||
</p>
|
||||
<p className="text-xs text-stone-500 dark:text-neutral-400">
|
||||
{t('agentworld.jobs.applyModal.successBody')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-stone-700 dark:text-neutral-300">
|
||||
Bid Amount
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bidAmount}
|
||||
onChange={e => setBidAmount(e.target.value)}
|
||||
className="w-full rounded border border-stone-300 bg-white px-2.5 py-1.5 text-sm text-stone-900 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
|
||||
placeholder="e.g. 450 USDC"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-stone-700 dark:text-neutral-300">
|
||||
Estimated Delivery
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={estimatedDelivery}
|
||||
onChange={e => setEstimatedDelivery(e.target.value)}
|
||||
className="w-full rounded border border-stone-300 bg-white px-2.5 py-1.5 text-sm text-stone-900 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
|
||||
placeholder="e.g. 2 weeks"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button type="button" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? 'Applying…' : 'Submit Application'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={e => {
|
||||
void handleSubmit(e);
|
||||
}}
|
||||
className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-stone-700 dark:text-neutral-300">
|
||||
{t('agentworld.jobs.applyModal.coverLetterLabel')}
|
||||
</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={coverLetter}
|
||||
onChange={e => setCoverLetter(e.target.value)}
|
||||
className="w-full rounded border border-stone-300 bg-white px-2.5 py-1.5 text-sm text-stone-900 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
|
||||
placeholder={t('agentworld.jobs.applyModal.coverLetterPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-stone-700 dark:text-neutral-300">
|
||||
{t('agentworld.jobs.applyModal.bidAmountLabel')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bidAmount}
|
||||
onChange={e => setBidAmount(e.target.value)}
|
||||
className="w-full rounded border border-stone-300 bg-white px-2.5 py-1.5 text-sm text-stone-900 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
|
||||
placeholder={t('agentworld.jobs.applyModal.bidAmountPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-stone-700 dark:text-neutral-300">
|
||||
{t('agentworld.jobs.applyModal.deliveryLabel')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={estimatedDelivery}
|
||||
onChange={e => setEstimatedDelivery(e.target.value)}
|
||||
className="w-full rounded border border-stone-300 bg-white px-2.5 py-1.5 text-sm text-stone-900 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
|
||||
placeholder={t('agentworld.jobs.applyModal.deliveryPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-red-600 dark:text-red-400">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button type="button" onClick={onClose} disabled={submitting}>
|
||||
{t('agentworld.jobs.applyModal.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting
|
||||
? t('agentworld.jobs.applyModal.submitting')
|
||||
: t('agentworld.jobs.applyModal.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</ModalShell>
|
||||
);
|
||||
}
|
||||
@@ -1244,7 +1280,9 @@ export default function JobsSection() {
|
||||
<ApplyModal
|
||||
jobId={applyingJobId}
|
||||
onClose={() => setApplyingJobId(null)}
|
||||
onApplied={() => setApplyingJobId(null)}
|
||||
onApplied={() => {
|
||||
refetchJobs();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{disputeJobId && (
|
||||
|
||||
@@ -5621,6 +5621,18 @@ const messages: TranslationMap = {
|
||||
'settings.profiles.editor.notFound': 'الملف غير موجود',
|
||||
'settings.profiles.editor.saving': 'جارٍ الحفظ…',
|
||||
'settings.profiles.editor.idRequired': 'لا يمكن أن يكون معرّف الملف فارغًا',
|
||||
'agentworld.jobs.applyModal.title': 'التقدم للوظيفة',
|
||||
'agentworld.jobs.applyModal.successHeading': 'تم تقديم العرض بنجاح!',
|
||||
'agentworld.jobs.applyModal.successBody': 'سيراجع ناشر الوظيفة طلبك.',
|
||||
'agentworld.jobs.applyModal.coverLetterLabel': 'خطاب التغطية',
|
||||
'agentworld.jobs.applyModal.coverLetterPlaceholder': 'صف خبرتك وسبب ملاءمتك لهذا الدور',
|
||||
'agentworld.jobs.applyModal.bidAmountLabel': 'مبلغ العطاء',
|
||||
'agentworld.jobs.applyModal.bidAmountPlaceholder': 'مثال: 450 USDC',
|
||||
'agentworld.jobs.applyModal.deliveryLabel': 'وقت التسليم المتوقع',
|
||||
'agentworld.jobs.applyModal.deliveryPlaceholder': 'مثال: أسبوعان',
|
||||
'agentworld.jobs.applyModal.cancel': 'إلغاء',
|
||||
'agentworld.jobs.applyModal.submit': 'إرسال الطلب',
|
||||
'agentworld.jobs.applyModal.submitting': 'جارٍ التقديم…',
|
||||
};
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -5735,6 +5735,19 @@ const messages: TranslationMap = {
|
||||
'settings.profiles.editor.notFound': 'প্রোফাইল পাওয়া যায়নি',
|
||||
'settings.profiles.editor.saving': 'সংরক্ষণ হচ্ছে…',
|
||||
'settings.profiles.editor.idRequired': 'প্রোফাইল আইডি খালি রাখা যাবে না',
|
||||
'agentworld.jobs.applyModal.title': 'চাকরির জন্য আবেদন করুন',
|
||||
'agentworld.jobs.applyModal.successHeading': 'প্রস্তাব সফলভাবে জমা দেওয়া হয়েছে!',
|
||||
'agentworld.jobs.applyModal.successBody': 'চাকরি পোস্টকারী আপনার আবেদন পর্যালোচনা করবেন।',
|
||||
'agentworld.jobs.applyModal.coverLetterLabel': 'কভার লেটার',
|
||||
'agentworld.jobs.applyModal.coverLetterPlaceholder':
|
||||
'আপনার অভিজ্ঞতা এবং কেন আপনি উপযুক্ত তা বর্ণনা করুন',
|
||||
'agentworld.jobs.applyModal.bidAmountLabel': 'বিড পরিমাণ',
|
||||
'agentworld.jobs.applyModal.bidAmountPlaceholder': 'যেমন: 450 USDC',
|
||||
'agentworld.jobs.applyModal.deliveryLabel': 'আনুমানিক ডেলিভারি',
|
||||
'agentworld.jobs.applyModal.deliveryPlaceholder': 'যেমন: 2 সপ্তাহ',
|
||||
'agentworld.jobs.applyModal.cancel': 'বাতিল',
|
||||
'agentworld.jobs.applyModal.submit': 'আবেদন জমা দিন',
|
||||
'agentworld.jobs.applyModal.submitting': 'আবেদন করা হচ্ছে…',
|
||||
};
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -5894,6 +5894,19 @@ const messages: TranslationMap = {
|
||||
'settings.profiles.editor.notFound': 'Profil nicht gefunden',
|
||||
'settings.profiles.editor.saving': 'Wird gespeichert…',
|
||||
'settings.profiles.editor.idRequired': 'Die Profil-Kennung darf nicht leer sein',
|
||||
'agentworld.jobs.applyModal.title': 'Auf Stelle bewerben',
|
||||
'agentworld.jobs.applyModal.successHeading': 'Vorschlag erfolgreich eingereicht!',
|
||||
'agentworld.jobs.applyModal.successBody': 'Der Auftraggeber wird Ihre Bewerbung prüfen.',
|
||||
'agentworld.jobs.applyModal.coverLetterLabel': 'Anschreiben',
|
||||
'agentworld.jobs.applyModal.coverLetterPlaceholder':
|
||||
'Beschreiben Sie Ihre Erfahrung und warum Sie gut geeignet sind',
|
||||
'agentworld.jobs.applyModal.bidAmountLabel': 'Gebotsbetrag',
|
||||
'agentworld.jobs.applyModal.bidAmountPlaceholder': 'z. B. 450 USDC',
|
||||
'agentworld.jobs.applyModal.deliveryLabel': 'Voraussichtliche Lieferzeit',
|
||||
'agentworld.jobs.applyModal.deliveryPlaceholder': 'z. B. 2 Wochen',
|
||||
'agentworld.jobs.applyModal.cancel': 'Abbrechen',
|
||||
'agentworld.jobs.applyModal.submit': 'Bewerbung einreichen',
|
||||
'agentworld.jobs.applyModal.submitting': 'Wird eingereicht…',
|
||||
};
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -6000,6 +6000,21 @@ const en: TranslationMap = {
|
||||
'notch.speaking': 'Speaking…',
|
||||
'notch.transcribing': 'Transcribing…',
|
||||
'notch.executing': 'Executing…',
|
||||
|
||||
// ── Agent World: Jobs apply modal ─────────────────────────────────────────
|
||||
'agentworld.jobs.applyModal.title': 'Apply for Job',
|
||||
'agentworld.jobs.applyModal.successHeading': 'Proposal submitted successfully!',
|
||||
'agentworld.jobs.applyModal.successBody': 'The job poster will review your application.',
|
||||
'agentworld.jobs.applyModal.coverLetterLabel': 'Cover Letter',
|
||||
'agentworld.jobs.applyModal.coverLetterPlaceholder':
|
||||
"Describe your experience and why you're a good fit",
|
||||
'agentworld.jobs.applyModal.bidAmountLabel': 'Bid Amount',
|
||||
'agentworld.jobs.applyModal.bidAmountPlaceholder': 'e.g. 450 USDC',
|
||||
'agentworld.jobs.applyModal.deliveryLabel': 'Estimated Delivery',
|
||||
'agentworld.jobs.applyModal.deliveryPlaceholder': 'e.g. 2 weeks',
|
||||
'agentworld.jobs.applyModal.cancel': 'Cancel',
|
||||
'agentworld.jobs.applyModal.submit': 'Submit Application',
|
||||
'agentworld.jobs.applyModal.submitting': 'Applying…',
|
||||
};
|
||||
|
||||
export default en;
|
||||
|
||||
@@ -5859,6 +5859,19 @@ const messages: TranslationMap = {
|
||||
'settings.profiles.editor.notFound': 'Perfil no encontrado',
|
||||
'settings.profiles.editor.saving': 'Guardando…',
|
||||
'settings.profiles.editor.idRequired': 'El identificador del perfil no puede estar vacío',
|
||||
'agentworld.jobs.applyModal.title': 'Solicitar trabajo',
|
||||
'agentworld.jobs.applyModal.successHeading': '¡Propuesta enviada con éxito!',
|
||||
'agentworld.jobs.applyModal.successBody': 'El publicador del trabajo revisará tu solicitud.',
|
||||
'agentworld.jobs.applyModal.coverLetterLabel': 'Carta de presentación',
|
||||
'agentworld.jobs.applyModal.coverLetterPlaceholder':
|
||||
'Describe tu experiencia y por qué eres la persona adecuada',
|
||||
'agentworld.jobs.applyModal.bidAmountLabel': 'Monto de la oferta',
|
||||
'agentworld.jobs.applyModal.bidAmountPlaceholder': 'ej. 450 USDC',
|
||||
'agentworld.jobs.applyModal.deliveryLabel': 'Entrega estimada',
|
||||
'agentworld.jobs.applyModal.deliveryPlaceholder': 'ej. 2 semanas',
|
||||
'agentworld.jobs.applyModal.cancel': 'Cancelar',
|
||||
'agentworld.jobs.applyModal.submit': 'Enviar solicitud',
|
||||
'agentworld.jobs.applyModal.submitting': 'Enviando…',
|
||||
};
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -5876,6 +5876,19 @@ const messages: TranslationMap = {
|
||||
'settings.profiles.editor.notFound': 'Profil introuvable',
|
||||
'settings.profiles.editor.saving': 'Enregistrement…',
|
||||
'settings.profiles.editor.idRequired': "L'identifiant du profil ne peut pas être vide",
|
||||
'agentworld.jobs.applyModal.title': "Postuler à l'offre",
|
||||
'agentworld.jobs.applyModal.successHeading': 'Proposition soumise avec succès !',
|
||||
'agentworld.jobs.applyModal.successBody': 'Le recruteur examinera votre candidature.',
|
||||
'agentworld.jobs.applyModal.coverLetterLabel': 'Lettre de motivation',
|
||||
'agentworld.jobs.applyModal.coverLetterPlaceholder':
|
||||
'Décrivez votre expérience et pourquoi vous êtes le bon candidat',
|
||||
'agentworld.jobs.applyModal.bidAmountLabel': 'Montant proposé',
|
||||
'agentworld.jobs.applyModal.bidAmountPlaceholder': 'ex. 450 USDC',
|
||||
'agentworld.jobs.applyModal.deliveryLabel': 'Délai estimé',
|
||||
'agentworld.jobs.applyModal.deliveryPlaceholder': 'ex. 2 semaines',
|
||||
'agentworld.jobs.applyModal.cancel': 'Annuler',
|
||||
'agentworld.jobs.applyModal.submit': 'Soumettre la candidature',
|
||||
'agentworld.jobs.applyModal.submitting': 'Envoi en cours…',
|
||||
};
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -5738,6 +5738,19 @@ const messages: TranslationMap = {
|
||||
'settings.profiles.editor.notFound': 'प्रोफ़ाइल नहीं मिली',
|
||||
'settings.profiles.editor.saving': 'सहेजा जा रहा है…',
|
||||
'settings.profiles.editor.idRequired': 'प्रोफ़ाइल आईडी खाली नहीं हो सकती',
|
||||
'agentworld.jobs.applyModal.title': 'नौकरी के लिए आवेदन करें',
|
||||
'agentworld.jobs.applyModal.successHeading': 'प्रस्ताव सफलतापूर्वक सबमिट हुआ!',
|
||||
'agentworld.jobs.applyModal.successBody': 'नौकरी पोस्टर आपके आवेदन की समीक्षा करेगा।',
|
||||
'agentworld.jobs.applyModal.coverLetterLabel': 'कवर लेटर',
|
||||
'agentworld.jobs.applyModal.coverLetterPlaceholder':
|
||||
'अपना अनुभव और इस भूमिका के लिए उपयुक्तता बताएं',
|
||||
'agentworld.jobs.applyModal.bidAmountLabel': 'बोली राशि',
|
||||
'agentworld.jobs.applyModal.bidAmountPlaceholder': 'उदा. 450 USDC',
|
||||
'agentworld.jobs.applyModal.deliveryLabel': 'अनुमानित डिलीवरी',
|
||||
'agentworld.jobs.applyModal.deliveryPlaceholder': 'उदा. 2 सप्ताह',
|
||||
'agentworld.jobs.applyModal.cancel': 'रद्द करें',
|
||||
'agentworld.jobs.applyModal.submit': 'आवेदन सबमिट करें',
|
||||
'agentworld.jobs.applyModal.submitting': 'आवेदन हो रहा है…',
|
||||
};
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -5755,6 +5755,19 @@ const messages: TranslationMap = {
|
||||
'settings.profiles.editor.notFound': 'Profil tidak ditemukan',
|
||||
'settings.profiles.editor.saving': 'Menyimpan…',
|
||||
'settings.profiles.editor.idRequired': 'ID profil tidak boleh kosong',
|
||||
'agentworld.jobs.applyModal.title': 'Lamar Pekerjaan',
|
||||
'agentworld.jobs.applyModal.successHeading': 'Proposal berhasil dikirim!',
|
||||
'agentworld.jobs.applyModal.successBody': 'Pemasang lowongan akan meninjau lamaran Anda.',
|
||||
'agentworld.jobs.applyModal.coverLetterLabel': 'Surat Lamaran',
|
||||
'agentworld.jobs.applyModal.coverLetterPlaceholder':
|
||||
'Jelaskan pengalaman Anda dan mengapa Anda cocok untuk posisi ini',
|
||||
'agentworld.jobs.applyModal.bidAmountLabel': 'Jumlah Tawaran',
|
||||
'agentworld.jobs.applyModal.bidAmountPlaceholder': 'mis. 450 USDC',
|
||||
'agentworld.jobs.applyModal.deliveryLabel': 'Estimasi Pengiriman',
|
||||
'agentworld.jobs.applyModal.deliveryPlaceholder': 'mis. 2 minggu',
|
||||
'agentworld.jobs.applyModal.cancel': 'Batal',
|
||||
'agentworld.jobs.applyModal.submit': 'Kirim Lamaran',
|
||||
'agentworld.jobs.applyModal.submitting': 'Melamar…',
|
||||
};
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -5844,6 +5844,20 @@ const messages: TranslationMap = {
|
||||
'settings.profiles.editor.notFound': 'Profilo non trovato',
|
||||
'settings.profiles.editor.saving': 'Salvataggio…',
|
||||
'settings.profiles.editor.idRequired': "L'identificativo del profilo non può essere vuoto",
|
||||
'agentworld.jobs.applyModal.title': 'Candidati al lavoro',
|
||||
'agentworld.jobs.applyModal.successHeading': 'Proposta inviata con successo!',
|
||||
'agentworld.jobs.applyModal.successBody':
|
||||
"Il pubblicatore dell'offerta esaminerà la tua candidatura.",
|
||||
'agentworld.jobs.applyModal.coverLetterLabel': 'Lettera di presentazione',
|
||||
'agentworld.jobs.applyModal.coverLetterPlaceholder':
|
||||
'Descrivi la tua esperienza e perché sei adatto a questo ruolo',
|
||||
'agentworld.jobs.applyModal.bidAmountLabel': 'Importo offerta',
|
||||
'agentworld.jobs.applyModal.bidAmountPlaceholder': 'es. 450 USDC',
|
||||
'agentworld.jobs.applyModal.deliveryLabel': 'Consegna stimata',
|
||||
'agentworld.jobs.applyModal.deliveryPlaceholder': 'es. 2 settimane',
|
||||
'agentworld.jobs.applyModal.cancel': 'Annulla',
|
||||
'agentworld.jobs.applyModal.submit': 'Invia candidatura',
|
||||
'agentworld.jobs.applyModal.submitting': 'Candidatura in corso…',
|
||||
};
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -5682,6 +5682,19 @@ const messages: TranslationMap = {
|
||||
'settings.profiles.editor.notFound': '프로필을 찾을 수 없습니다',
|
||||
'settings.profiles.editor.saving': '저장 중…',
|
||||
'settings.profiles.editor.idRequired': '프로필 식별자는 비워 둘 수 없습니다',
|
||||
'agentworld.jobs.applyModal.title': '채용 지원',
|
||||
'agentworld.jobs.applyModal.successHeading': '제안이 성공적으로 제출되었습니다!',
|
||||
'agentworld.jobs.applyModal.successBody': '채용 공고 게시자가 지원서를 검토할 것입니다.',
|
||||
'agentworld.jobs.applyModal.coverLetterLabel': '자기소개서',
|
||||
'agentworld.jobs.applyModal.coverLetterPlaceholder':
|
||||
'귀하의 경험과 이 직무에 적합한 이유를 설명하세요',
|
||||
'agentworld.jobs.applyModal.bidAmountLabel': '제안 금액',
|
||||
'agentworld.jobs.applyModal.bidAmountPlaceholder': '예: 450 USDC',
|
||||
'agentworld.jobs.applyModal.deliveryLabel': '예상 납기',
|
||||
'agentworld.jobs.applyModal.deliveryPlaceholder': '예: 2주',
|
||||
'agentworld.jobs.applyModal.cancel': '취소',
|
||||
'agentworld.jobs.applyModal.submit': '지원서 제출',
|
||||
'agentworld.jobs.applyModal.submitting': '지원 중…',
|
||||
};
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -5827,6 +5827,19 @@ const messages: TranslationMap = {
|
||||
'settings.profiles.editor.notFound': 'Nie znaleziono profilu',
|
||||
'settings.profiles.editor.saving': 'Zapisywanie…',
|
||||
'settings.profiles.editor.idRequired': 'Identyfikator profilu nie może być pusty',
|
||||
'agentworld.jobs.applyModal.title': 'Aplikuj na stanowisko',
|
||||
'agentworld.jobs.applyModal.successHeading': 'Oferta złożona pomyślnie!',
|
||||
'agentworld.jobs.applyModal.successBody': 'Pracodawca przejrzy Twoją aplikację.',
|
||||
'agentworld.jobs.applyModal.coverLetterLabel': 'List motywacyjny',
|
||||
'agentworld.jobs.applyModal.coverLetterPlaceholder':
|
||||
'Opisz swoje doświadczenie i dlaczego pasujesz do tej roli',
|
||||
'agentworld.jobs.applyModal.bidAmountLabel': 'Kwota oferty',
|
||||
'agentworld.jobs.applyModal.bidAmountPlaceholder': 'np. 450 USDC',
|
||||
'agentworld.jobs.applyModal.deliveryLabel': 'Szacowany czas realizacji',
|
||||
'agentworld.jobs.applyModal.deliveryPlaceholder': 'np. 2 tygodnie',
|
||||
'agentworld.jobs.applyModal.cancel': 'Anuluj',
|
||||
'agentworld.jobs.applyModal.submit': 'Wyślij aplikację',
|
||||
'agentworld.jobs.applyModal.submitting': 'Wysyłanie…',
|
||||
};
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -5839,6 +5839,19 @@ const messages: TranslationMap = {
|
||||
'settings.profiles.editor.notFound': 'Perfil não encontrado',
|
||||
'settings.profiles.editor.saving': 'Salvando…',
|
||||
'settings.profiles.editor.idRequired': 'O identificador do perfil não pode estar vazio',
|
||||
'agentworld.jobs.applyModal.title': 'Candidatar-se à vaga',
|
||||
'agentworld.jobs.applyModal.successHeading': 'Proposta enviada com sucesso!',
|
||||
'agentworld.jobs.applyModal.successBody': 'O publicador da vaga irá analisar a sua candidatura.',
|
||||
'agentworld.jobs.applyModal.coverLetterLabel': 'Carta de apresentação',
|
||||
'agentworld.jobs.applyModal.coverLetterPlaceholder':
|
||||
'Descreva a sua experiência e por que você é adequado para esta função',
|
||||
'agentworld.jobs.applyModal.bidAmountLabel': 'Valor da proposta',
|
||||
'agentworld.jobs.applyModal.bidAmountPlaceholder': 'ex. 450 USDC',
|
||||
'agentworld.jobs.applyModal.deliveryLabel': 'Prazo estimado de entrega',
|
||||
'agentworld.jobs.applyModal.deliveryPlaceholder': 'ex. 2 semanas',
|
||||
'agentworld.jobs.applyModal.cancel': 'Cancelar',
|
||||
'agentworld.jobs.applyModal.submit': 'Enviar candidatura',
|
||||
'agentworld.jobs.applyModal.submitting': 'A enviar…',
|
||||
};
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -5800,6 +5800,19 @@ const messages: TranslationMap = {
|
||||
'settings.profiles.editor.notFound': 'Профиль не найден',
|
||||
'settings.profiles.editor.saving': 'Сохранение…',
|
||||
'settings.profiles.editor.idRequired': 'Идентификатор профиля не может быть пустым',
|
||||
'agentworld.jobs.applyModal.title': 'Откликнуться на вакансию',
|
||||
'agentworld.jobs.applyModal.successHeading': 'Предложение успешно отправлено!',
|
||||
'agentworld.jobs.applyModal.successBody': 'Работодатель рассмотрит вашу заявку.',
|
||||
'agentworld.jobs.applyModal.coverLetterLabel': 'Сопроводительное письмо',
|
||||
'agentworld.jobs.applyModal.coverLetterPlaceholder':
|
||||
'Опишите свой опыт и почему вы подходите для этой роли',
|
||||
'agentworld.jobs.applyModal.bidAmountLabel': 'Сумма предложения',
|
||||
'agentworld.jobs.applyModal.bidAmountPlaceholder': 'напр. 450 USDC',
|
||||
'agentworld.jobs.applyModal.deliveryLabel': 'Ориентировочный срок выполнения',
|
||||
'agentworld.jobs.applyModal.deliveryPlaceholder': 'напр. 2 недели',
|
||||
'agentworld.jobs.applyModal.cancel': 'Отмена',
|
||||
'agentworld.jobs.applyModal.submit': 'Отправить заявку',
|
||||
'agentworld.jobs.applyModal.submitting': 'Отправка…',
|
||||
};
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -5447,6 +5447,18 @@ const messages: TranslationMap = {
|
||||
'settings.profiles.editor.notFound': '未找到配置',
|
||||
'settings.profiles.editor.saving': '正在保存…',
|
||||
'settings.profiles.editor.idRequired': '配置标识不能为空',
|
||||
'agentworld.jobs.applyModal.title': '申请职位',
|
||||
'agentworld.jobs.applyModal.successHeading': '提案提交成功!',
|
||||
'agentworld.jobs.applyModal.successBody': '招聘方将审核您的申请。',
|
||||
'agentworld.jobs.applyModal.coverLetterLabel': '求职信',
|
||||
'agentworld.jobs.applyModal.coverLetterPlaceholder': '描述您的经验以及为何适合此职位',
|
||||
'agentworld.jobs.applyModal.bidAmountLabel': '报价金额',
|
||||
'agentworld.jobs.applyModal.bidAmountPlaceholder': '例:450 USDC',
|
||||
'agentworld.jobs.applyModal.deliveryLabel': '预计交付时间',
|
||||
'agentworld.jobs.applyModal.deliveryPlaceholder': '例:2周',
|
||||
'agentworld.jobs.applyModal.cancel': '取消',
|
||||
'agentworld.jobs.applyModal.submit': '提交申请',
|
||||
'agentworld.jobs.applyModal.submitting': '申请中…',
|
||||
};
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -32,6 +32,7 @@ mod schemas;
|
||||
pub(crate) mod signal_store;
|
||||
mod state;
|
||||
pub(crate) mod streams;
|
||||
pub mod tools;
|
||||
|
||||
#[cfg(test)]
|
||||
mod signal_e2e_tests;
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
//! LLM-callable agent tools for the `tinyplace` domain.
|
||||
//!
|
||||
//! Exposes write actions (job proposal submission) to the agent tool-call
|
||||
//! pipeline. The candidate is always resolved server-side from the wallet
|
||||
//! signer (`signer.agent_id()`) to prevent impersonation — it is never
|
||||
//! accepted as a tool argument.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use tinyplace::types::ProposalCreateRequest;
|
||||
|
||||
use crate::openhuman::tinyplace::ops::{global_state, map_err};
|
||||
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult};
|
||||
|
||||
const LOG_PREFIX: &str = "[tinyplace][tool]";
|
||||
|
||||
// ── TinyplaceJobApplyTool ─────────────────────────────────────────────────────
|
||||
|
||||
/// Submit a proposal (apply) to an open tiny.place job on behalf of the user.
|
||||
///
|
||||
/// The candidate identity is always derived from the user's wallet signer
|
||||
/// (`signer.agent_id()`) — it cannot be overridden via tool arguments,
|
||||
/// preventing any impersonation of another user.
|
||||
///
|
||||
/// Job proposals are free (directory-signed POST, no x402 payment). The
|
||||
/// escrow/payment only happens when the job poster selects a candidate.
|
||||
pub struct TinyplaceJobApplyTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TinyplaceJobApplyTool {
|
||||
fn name(&self) -> &str {
|
||||
"tinyplace_job_apply"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Submit a proposal (apply) to an open tiny.place job on behalf of the user. \
|
||||
Requires job_id. Optionally include a cover_letter, bid_amount (e.g. '450 USDC'), \
|
||||
estimated_delivery (e.g. '2 weeks'), and past_work URLs. \
|
||||
The candidate is always resolved from the user's wallet signer — it cannot \
|
||||
be supplied as an argument. Proposals are free (no payment required). \
|
||||
This is a write action: it submits an application on the user's behalf."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"job_id": {
|
||||
"type": "string",
|
||||
"description": "The tiny.place job ID to apply for."
|
||||
},
|
||||
"cover_letter": {
|
||||
"type": "string",
|
||||
"description": "Optional cover letter describing experience and fit for the role."
|
||||
},
|
||||
"bid_amount": {
|
||||
"type": "string",
|
||||
"description": "Optional bid amount, e.g. '450 USDC'."
|
||||
},
|
||||
"estimated_delivery": {
|
||||
"type": "string",
|
||||
"description": "Optional estimated delivery time, e.g. '2 weeks'."
|
||||
},
|
||||
"past_work": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Optional list of past work URLs or descriptions."
|
||||
}
|
||||
},
|
||||
"required": ["job_id"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let job_id = args
|
||||
.get("job_id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| anyhow::anyhow!("missing required parameter 'job_id'"))?;
|
||||
|
||||
let cover_letter = args
|
||||
.get("cover_letter")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string);
|
||||
|
||||
let bid_amount = args
|
||||
.get("bid_amount")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string);
|
||||
|
||||
let estimated_delivery = args
|
||||
.get("estimated_delivery")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string);
|
||||
|
||||
let past_work: Option<Vec<String>> = args
|
||||
.get("past_work")
|
||||
.and_then(|v| if v.is_null() { None } else { Some(v) })
|
||||
.map(|v| {
|
||||
serde_json::from_value::<Vec<String>>(v.clone())
|
||||
.map_err(|e| anyhow::anyhow!("invalid 'past_work' param: {e}"))
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
log::debug!(
|
||||
"{LOG_PREFIX} tinyplace_job_apply job_id={job_id} \
|
||||
has_cover_letter={} has_bid={} has_delivery={} past_work_count={}",
|
||||
cover_letter.is_some(),
|
||||
bid_amount.is_some(),
|
||||
estimated_delivery.is_some(),
|
||||
past_work.as_ref().map(|v| v.len()).unwrap_or(0),
|
||||
);
|
||||
|
||||
// Resolve candidate anti-spoof: always derived from the wallet signer.
|
||||
// The agent cannot supply a candidate arg — the signer is the source of truth.
|
||||
let client = global_state()
|
||||
.client()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("tinyplace client unavailable: {e}"))?;
|
||||
|
||||
let signer = client
|
||||
.http()
|
||||
.signer()
|
||||
.ok_or_else(|| anyhow::anyhow!("tiny.place signer unavailable; unlock your wallet"))?;
|
||||
|
||||
// Candidate is always from the signer — not from tool arguments.
|
||||
let candidate = signer.agent_id();
|
||||
|
||||
log::debug!("{LOG_PREFIX} tinyplace_job_apply candidate_resolved=true job_id={job_id}");
|
||||
|
||||
let request = ProposalCreateRequest {
|
||||
candidate,
|
||||
cover_letter,
|
||||
bid_amount,
|
||||
estimated_delivery,
|
||||
past_work,
|
||||
};
|
||||
|
||||
let result = client
|
||||
.jobs
|
||||
.apply(&job_id, &request)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{}", map_err(e)))?;
|
||||
|
||||
log::debug!(
|
||||
"{LOG_PREFIX} tinyplace_job_apply success proposal_id={}",
|
||||
result.proposal_id
|
||||
);
|
||||
|
||||
let output = serde_json::to_string(&result)
|
||||
.map_err(|e| anyhow::anyhow!("tinyplace serialise: {e}"))?;
|
||||
|
||||
Ok(ToolResult::success(output))
|
||||
}
|
||||
|
||||
fn permission_level(&self) -> PermissionLevel {
|
||||
// Write — submits a proposal on the user's behalf.
|
||||
PermissionLevel::Write
|
||||
}
|
||||
|
||||
fn external_effect(&self) -> bool {
|
||||
// POSTs a proposal to an external service.
|
||||
true
|
||||
}
|
||||
|
||||
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::openhuman::tools::traits::{PermissionLevel, ToolScope};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn tool_metadata() {
|
||||
let tool = TinyplaceJobApplyTool;
|
||||
assert_eq!(tool.name(), "tinyplace_job_apply");
|
||||
assert_eq!(tool.permission_level(), PermissionLevel::Write);
|
||||
assert_eq!(tool.scope(), ToolScope::All);
|
||||
assert!(tool.external_effect());
|
||||
assert!(!tool.is_concurrency_safe(&json!({})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameters_schema_requires_job_id() {
|
||||
let schema = TinyplaceJobApplyTool.parameters_schema();
|
||||
let required = schema["required"].as_array().expect("required array");
|
||||
assert!(required.iter().any(|v| v.as_str() == Some("job_id")));
|
||||
|
||||
// Candidate must NOT be in the schema — it's resolved server-side.
|
||||
let props = schema["properties"].as_object().expect("properties object");
|
||||
assert!(
|
||||
!props.contains_key("candidate"),
|
||||
"candidate must not be a tool argument (anti-spoof)"
|
||||
);
|
||||
assert!(props.contains_key("job_id"));
|
||||
assert!(props.contains_key("cover_letter"));
|
||||
assert!(props.contains_key("bid_amount"));
|
||||
assert!(props.contains_key("estimated_delivery"));
|
||||
assert!(props.contains_key("past_work"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_job_id_returns_error_before_client() {
|
||||
// Passes empty args — should fail with a clear error before
|
||||
// attempting any network call or client initialisation.
|
||||
let result = TinyplaceJobApplyTool.execute(json!({})).await;
|
||||
assert!(result.is_err());
|
||||
let msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
msg.contains("job_id"),
|
||||
"error should mention 'job_id', got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_job_id_with_other_fields_still_errors() {
|
||||
let result = TinyplaceJobApplyTool
|
||||
.execute(json!({
|
||||
"cover_letter": "Great project",
|
||||
"bid_amount": "100 USDC"
|
||||
}))
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
let msg = result.unwrap_err().to_string();
|
||||
assert!(msg.contains("job_id"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn blank_job_id_returns_error_before_client() {
|
||||
let result = TinyplaceJobApplyTool
|
||||
.execute(json!({ "job_id": " " }))
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
let msg = result.unwrap_err().to_string();
|
||||
assert!(msg.contains("job_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proposal_create_request_shape_from_tool_args() {
|
||||
// Verify the ProposalCreateRequest struct can be constructed as the
|
||||
// tool would produce it — candidate always from signer, never from args.
|
||||
let candidate = "agent_1abc".to_string();
|
||||
let request = ProposalCreateRequest {
|
||||
candidate: candidate.clone(),
|
||||
cover_letter: Some("I can do this".to_string()),
|
||||
bid_amount: Some("200 USDC".to_string()),
|
||||
estimated_delivery: Some("1 week".to_string()),
|
||||
past_work: Some(vec!["https://example.com/project".to_string()]),
|
||||
};
|
||||
assert_eq!(request.candidate, candidate);
|
||||
assert_eq!(request.cover_letter.as_deref(), Some("I can do this"));
|
||||
assert_eq!(request.bid_amount.as_deref(), Some("200 USDC"));
|
||||
assert_eq!(request.estimated_delivery.as_deref(), Some("1 week"));
|
||||
assert_eq!(request.past_work.as_ref().map(|v| v.len()), Some(1));
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ pub use crate::openhuman::skill_runtime::tools::*;
|
||||
pub use crate::openhuman::task_sources::tools::*;
|
||||
pub use crate::openhuman::team::tools::*;
|
||||
pub use crate::openhuman::threads::tools::*;
|
||||
pub use crate::openhuman::tinyplace::tools::*;
|
||||
pub use crate::openhuman::todos::tools::*;
|
||||
pub use crate::openhuman::wallet::tools::*;
|
||||
pub use crate::openhuman::whatsapp_data::tools::*;
|
||||
|
||||
@@ -515,6 +515,11 @@ pub fn all_tools_with_runtime(
|
||||
Box::new(WorkspaceUpdatePersonaTool::new(config.clone())),
|
||||
Box::new(WorkspaceResetPersonaTool::new(config.clone())),
|
||||
Box::new(WorkspaceInitTool),
|
||||
// tiny.place agent tools — submit proposals on behalf of the user.
|
||||
// Write-level: requires supervised/full autonomy to run without prompting.
|
||||
// Always registered; actual network call fails gracefully when the wallet
|
||||
// is locked or TINYPLACE_API_BASE_URL is unavailable.
|
||||
Box::new(TinyplaceJobApplyTool),
|
||||
];
|
||||
|
||||
log::debug!(
|
||||
|
||||
Reference in New Issue
Block a user